refactor: Snapshot Externe — layout 3 lignes temporelles × 4 piliers
Remplace le 2×2 indépendant par une matrice cohérente avec le concept E1/E2/E3 : - 3 lignes (Long/Moyen/Court), chacune ancrée sur l'événement actif de ce niveau - 4 colonnes (Géopolitique / Prix & Marchés / Régimes Macro / Calendrier) - Assets affichés dans la colonne Prix filtrés selon affected_assets de l'événement - Barres macro normalisées sur max score (overflow corrigé, overflow-hidden) - Calendrier filtré par horizon temporel (7j / 1M / 3M) selon le niveau - En-têtes colonnes globaux pour lecture matricielle immédiate Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,14 @@ import axios from 'axios'
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import {
|
import {
|
||||||
Globe, BarChart2, Activity, Calendar,
|
Globe, BarChart2, Activity, Calendar,
|
||||||
ArrowRight, Clock, TrendingUp, TrendingDown, Minus, ExternalLink,
|
ArrowRight, Clock, TrendingUp, TrendingDown, Minus,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const api = axios.create({ baseURL: '/api' })
|
const api = axios.create({ baseURL: '/api' })
|
||||||
|
|
||||||
function todayStr() { return new Date().toISOString().split('T')[0] }
|
function todayStr() { return new Date().toISOString().split('T')[0] }
|
||||||
function fmtDate(d: string) {
|
function fmtShort(d: string) {
|
||||||
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })
|
return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short' })
|
||||||
}
|
}
|
||||||
function daysSince(ref: string, start: string) {
|
function daysSince(ref: string, start: string) {
|
||||||
return Math.floor((new Date(ref).getTime() - new Date(start).getTime()) / 86_400_000)
|
return Math.floor((new Date(ref).getTime() - new Date(start).getTime()) / 86_400_000)
|
||||||
@@ -25,7 +25,8 @@ function offsetDate(d: string, days: number) {
|
|||||||
|
|
||||||
interface MarketEvent {
|
interface MarketEvent {
|
||||||
id: number; name: string; start_date: string; end_date: string | null
|
id: number; name: string; start_date: string; end_date: string | null
|
||||||
level: string; category: string; description: string; impact_score: number
|
level: string; category: string; description: string; market_impact: string
|
||||||
|
affected_assets: string; impact_score: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DayContext {
|
interface DayContext {
|
||||||
@@ -36,285 +37,346 @@ interface DayContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Quote { symbol: string; price: number; change_pct: number; name?: string }
|
interface Quote { symbol: string; price: number; change_pct: number; name?: string }
|
||||||
|
|
||||||
interface MacroRegime {
|
interface MacroRegime {
|
||||||
scenarios: { dominant: string; scores: Record<string, number> }
|
scenarios: { dominant: string; scores: Record<string, number> }
|
||||||
gauges: Record<string, { value: number | null; label: string; change_pct?: number | null }>
|
gauges: Record<string, { value: number | null; label: string; change_pct?: number | null }>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CalEvent { title: string; date: string; importance: number; currency?: string }
|
interface CalEvent { title: string; date: string; importance: number; currency?: string }
|
||||||
|
|
||||||
// ── Sub-components ─────────────────────────────────────────────────────────────
|
// ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LEVEL_CFG = {
|
const LEVEL = {
|
||||||
long: { label: 'L', color: 'text-violet-400', bg: 'bg-violet-900/30', border: 'border-violet-700/40' },
|
long: {
|
||||||
medium: { label: 'M', color: 'text-blue-400', bg: 'bg-blue-900/30', border: 'border-blue-700/40' },
|
label: 'Long Terme', badge: 'L',
|
||||||
short: { label: 'C', color: 'text-emerald-400', bg: 'bg-emerald-900/30', border: 'border-emerald-700/40' },
|
ring: 'border-violet-700/50', bg: 'bg-violet-950/20',
|
||||||
|
header: 'bg-violet-950/40 border-violet-700/40',
|
||||||
|
accent: 'text-violet-400', dot: 'bg-violet-500',
|
||||||
|
badgeCls: 'bg-violet-800 text-violet-200',
|
||||||
|
},
|
||||||
|
medium: {
|
||||||
|
label: 'Moyen Terme', badge: 'M',
|
||||||
|
ring: 'border-blue-700/50', bg: 'bg-blue-950/20',
|
||||||
|
header: 'bg-blue-950/40 border-blue-700/40',
|
||||||
|
accent: 'text-blue-400', dot: 'bg-blue-500',
|
||||||
|
badgeCls: 'bg-blue-800 text-blue-200',
|
||||||
|
},
|
||||||
|
short: {
|
||||||
|
label: 'Court Terme', badge: 'C',
|
||||||
|
ring: 'border-emerald-700/50', bg: 'bg-emerald-950/20',
|
||||||
|
header: 'bg-emerald-950/40 border-emerald-700/40',
|
||||||
|
accent: 'text-emerald-400', dot: 'bg-emerald-500',
|
||||||
|
badgeCls: 'bg-emerald-800 text-emerald-200',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const REGIME_LABELS: Record<string, string> = {
|
||||||
|
risk_on: 'Risk On', risk_off: 'Risk Off', stagflation: 'Stagflation',
|
||||||
|
goldilocks: 'Goldilocks', recession: 'Récession', reflation: 'Reflation',
|
||||||
|
soft_landing: 'Soft Landing', desinflation: 'Désinflation', incertain: 'Incertain',
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventBadge({ ev, level, refDate }: { ev: MarketEvent | null; level: 'long' | 'medium' | 'short'; refDate: string }) {
|
// Assets relevant to each temporal level — fallback to standard set
|
||||||
const cfg = LEVEL_CFG[level]
|
const LEVEL_ASSETS: Record<string, string[]> = {
|
||||||
|
long: ['SPY', 'GLD', 'USO', 'TLT'],
|
||||||
|
medium: ['SPY', 'USO', 'GLD', 'UUP'],
|
||||||
|
short: ['QQQ', 'TLT', 'UUP', 'GLD'],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to map affected_assets field to quote symbols we have
|
||||||
|
function relevantAssets(ev: MarketEvent | null, level: string): string[] {
|
||||||
|
if (!ev) return LEVEL_ASSETS[level] ?? []
|
||||||
|
try {
|
||||||
|
const raw: string[] = JSON.parse(ev.affected_assets || '[]')
|
||||||
|
const MAP: Record<string, string> = {
|
||||||
|
SPX: 'SPY', SPY: 'SPY', QQQ: 'QQQ', NVDA: 'NVDA',
|
||||||
|
GC: 'GLD', Gold: 'GLD', GLD: 'GLD',
|
||||||
|
CL: 'USO', Oil: 'USO', USO: 'USO',
|
||||||
|
NG: 'USO', TLT: 'TLT', USD: 'UUP', UUP: 'UUP',
|
||||||
|
VIX: 'VIX', HYG: 'HYG',
|
||||||
|
}
|
||||||
|
const mapped = raw.map(a => MAP[a]).filter(Boolean) as string[]
|
||||||
|
const unique = [...new Set(mapped)].slice(0, 4)
|
||||||
|
return unique.length >= 2 ? unique : LEVEL_ASSETS[level] ?? []
|
||||||
|
} catch { return LEVEL_ASSETS[level] ?? [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cells ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function CellGeo({
|
||||||
|
ev, level, refDate,
|
||||||
|
}: {
|
||||||
|
ev: MarketEvent | null; level: keyof typeof LEVEL; refDate: string
|
||||||
|
}) {
|
||||||
|
const cfg = LEVEL[level]
|
||||||
if (!ev) return (
|
if (!ev) return (
|
||||||
<div className={clsx('flex items-center gap-2 rounded-lg px-3 py-2 border opacity-40', cfg.bg, cfg.border)}>
|
<div className="flex items-center justify-center h-full text-xs text-slate-600 italic p-3">
|
||||||
<span className={clsx('text-xs font-bold w-4 shrink-0', cfg.color)}>{cfg.label}</span>
|
Aucun événement {cfg.label.toLowerCase()} actif
|
||||||
<span className="text-xs text-slate-600 italic">—</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
const days = daysSince(refDate, ev.start_date)
|
const days = daysSince(refDate, ev.start_date)
|
||||||
return (
|
return (
|
||||||
<div className={clsx('flex items-center gap-2 rounded-lg px-3 py-2 border', cfg.bg, cfg.border)}>
|
<div className="p-3 space-y-2 h-full">
|
||||||
<span className={clsx('text-xs font-bold w-4 shrink-0', cfg.color)}>{cfg.label}</span>
|
<div className={clsx('text-sm font-bold leading-tight', cfg.accent)}>{ev.name}</div>
|
||||||
<span className={clsx('text-xs font-semibold flex-1 truncate', cfg.color)}>{ev.name}</span>
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
<span className="text-xs text-slate-500 shrink-0 tabular-nums">J+{days}</span>
|
<Clock className="w-3 h-3 shrink-0" />
|
||||||
|
<span>{ev.start_date}</span>
|
||||||
|
<span className={clsx('font-semibold', cfg.accent)}>J+{days}</span>
|
||||||
|
{!ev.end_date && <span className="text-amber-500/70">en cours</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed line-clamp-3">{ev.description}</p>
|
||||||
|
{ev.market_impact && (
|
||||||
|
<p className="text-xs text-slate-600 italic line-clamp-2">{ev.market_impact}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pillar 1 — Géopolitique / Temporal Context
|
function CellMarkets({
|
||||||
function PillarGeo({ ctx, refDate }: { ctx: DayContext | null; refDate: string }) {
|
ev, level, quotes,
|
||||||
|
}: {
|
||||||
|
ev: MarketEvent | null; level: string; quotes: Record<string, Quote> | null
|
||||||
|
}) {
|
||||||
|
const assets = relevantAssets(ev, level)
|
||||||
return (
|
return (
|
||||||
<div className="bg-dark-800 rounded-xl border border-violet-700/30 p-5 flex flex-col gap-3">
|
<div className="p-3 space-y-1.5 h-full">
|
||||||
<div className="flex items-center justify-between">
|
{assets.map(sym => {
|
||||||
<div className="flex items-center gap-2">
|
const q = quotes?.[sym]
|
||||||
<Globe className="w-4 h-4 text-violet-400" />
|
if (!q) return (
|
||||||
<span className="text-sm font-semibold text-violet-300">Géopolitique</span>
|
<div key={sym} className="flex items-center justify-between py-0.5">
|
||||||
</div>
|
<span className="text-xs text-slate-600">{sym}</span>
|
||||||
<Link to={`/timeline?date=${refDate}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-violet-400 transition-colors">
|
<span className="text-xs text-slate-700">—</span>
|
||||||
Timeline <ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{ctx ? (
|
|
||||||
<>
|
|
||||||
<EventBadge ev={ctx.events.long} level="long" refDate={refDate} />
|
|
||||||
<EventBadge ev={ctx.events.medium} level="medium" refDate={refDate} />
|
|
||||||
<EventBadge ev={ctx.events.short} level="short" refDate={refDate} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{(['long', 'medium', 'short'] as const).map(l => (
|
|
||||||
<div key={l} className={clsx('h-9 rounded-lg border animate-pulse', LEVEL_CFG[l].bg, LEVEL_CFG[l].border)} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
const up = q.change_pct > 0.1
|
||||||
|
const dn = q.change_pct < -0.1
|
||||||
|
return (
|
||||||
|
<div key={sym} className="flex items-center justify-between py-0.5">
|
||||||
|
<span className="text-xs text-slate-400 truncate max-w-[6rem]">{q.name || sym}</span>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<span className="text-xs text-slate-300 tabular-nums">{q.price?.toFixed(q.price > 100 ? 1 : 2)}</span>
|
||||||
|
<span className={clsx(
|
||||||
|
'text-xs flex items-center gap-0.5 tabular-nums w-14 justify-end',
|
||||||
|
up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500'
|
||||||
|
)}>
|
||||||
|
{up ? <TrendingUp className="w-2.5 h-2.5" /> : dn ? <TrendingDown className="w-2.5 h-2.5" /> : <Minus className="w-2.5 h-2.5" />}
|
||||||
|
{q.change_pct > 0 ? '+' : ''}{q.change_pct?.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CellMacro({
|
||||||
|
level, macro,
|
||||||
|
}: {
|
||||||
|
level: keyof typeof LEVEL; macro: MacroRegime | null
|
||||||
|
}) {
|
||||||
|
const cfg = LEVEL[level]
|
||||||
|
const dominant = macro?.scenarios?.dominant ?? 'incertain'
|
||||||
|
const dominantLabel = REGIME_LABELS[dominant] ?? dominant
|
||||||
|
|
||||||
|
const scores = macro?.scenarios?.scores ?? {}
|
||||||
|
const maxScore = Math.max(...Object.values(scores).map(Number), 1)
|
||||||
|
const top3 = Object.entries(scores)
|
||||||
|
.sort((a, b) => Number(b[1]) - Number(a[1]))
|
||||||
|
.slice(0, 3)
|
||||||
|
|
||||||
|
// Key gauge per level
|
||||||
|
const gaugeKeys = {
|
||||||
|
long: ['vix', 'us10y'],
|
||||||
|
medium: ['dxy', 'credit_spread'],
|
||||||
|
short: ['vix', 'dxy'],
|
||||||
|
}[level] ?? ['vix', 'dxy']
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-2.5 h-full">
|
||||||
|
<div className={clsx('text-sm font-bold', cfg.accent)}>{dominantLabel}</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{top3.map(([name, score]) => {
|
||||||
|
const pct = Math.min(100, Math.round((Number(score) / maxScore) * 100))
|
||||||
|
const label = REGIME_LABELS[name] ?? name
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500 w-20 truncate">{label}</span>
|
||||||
|
<div className="flex-1 bg-dark-700 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={clsx('h-1.5 rounded-full transition-all', cfg.dot)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-600 w-7 text-right tabular-nums">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap pt-1 border-t border-slate-700/20">
|
||||||
|
{gaugeKeys.map(k => {
|
||||||
|
const g = macro?.gauges?.[k]
|
||||||
|
if (!g?.value) return null
|
||||||
|
const up = (g.change_pct ?? 0) > 0
|
||||||
|
return (
|
||||||
|
<div key={k} className="flex items-center gap-1 bg-dark-900/40 rounded px-2 py-1">
|
||||||
|
<span className="text-xs text-slate-500">{g.label}</span>
|
||||||
|
<span className={clsx('text-xs font-medium tabular-nums', up ? 'text-emerald-400' : 'text-red-400')}>
|
||||||
|
{g.value?.toFixed(g.value > 10 ? 1 : 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calendar events filtered by temporal horizon relative to refDate
|
||||||
|
function calForLevel(events: CalEvent[], level: string, refDate: string): CalEvent[] {
|
||||||
|
const horizons: Record<string, number> = { long: 90, medium: 30, short: 7 }
|
||||||
|
const horizon = horizons[level] ?? 30
|
||||||
|
return events
|
||||||
|
.filter(e => e.date >= refDate && e.date <= offsetDate(refDate, horizon))
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
.slice(0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CellCalendar({
|
||||||
|
level, events, refDate,
|
||||||
|
}: {
|
||||||
|
level: keyof typeof LEVEL; events: CalEvent[] | null; refDate: string
|
||||||
|
}) {
|
||||||
|
const cfg = LEVEL[level]
|
||||||
|
const horizon = { long: '3 mois', medium: '1 mois', short: '7 jours' }[level]
|
||||||
|
const items = calForLevel(events ?? [], level, refDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-1.5 h-full">
|
||||||
|
<div className="text-xs text-slate-600 mb-2">Horizon {horizon}</div>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="text-xs text-slate-700 italic">Aucun événement</div>
|
||||||
|
)}
|
||||||
|
{items.map((ev, i) => {
|
||||||
|
const imp = ev.importance ?? 1
|
||||||
|
const impColor = imp >= 3 ? 'text-red-400' : imp >= 2 ? 'text-yellow-400' : 'text-slate-500'
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-start gap-2">
|
||||||
|
<span className="text-xs text-slate-600 shrink-0 w-14">{fmtShort(ev.date)}</span>
|
||||||
|
<span className={clsx('text-xs truncate flex-1', impColor)}>
|
||||||
|
{ev.currency && <span className="text-slate-600 mr-1">[{ev.currency}]</span>}
|
||||||
|
{ev.title}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-0.5 shrink-0 mt-0.5">
|
||||||
|
{[1, 2, 3].map(n => (
|
||||||
|
<div key={n} className={clsx(
|
||||||
|
'w-1 h-1 rounded-full',
|
||||||
|
n <= imp
|
||||||
|
? (imp >= 3 ? 'bg-red-500' : imp >= 2 ? 'bg-yellow-500' : 'bg-slate-500')
|
||||||
|
: 'bg-slate-700'
|
||||||
|
)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Temporal Row ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TemporalRow({
|
||||||
|
level, ev, quotes, macro, calEvents, refDate,
|
||||||
|
}: {
|
||||||
|
level: keyof typeof LEVEL
|
||||||
|
ev: MarketEvent | null
|
||||||
|
quotes: Record<string, Quote> | null
|
||||||
|
macro: MacroRegime | null
|
||||||
|
calEvents: CalEvent[] | null
|
||||||
|
refDate: string
|
||||||
|
}) {
|
||||||
|
const cfg = LEVEL[level]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rounded-xl border overflow-hidden', cfg.ring)}>
|
||||||
|
{/* Row header */}
|
||||||
|
<div className={clsx('flex items-center gap-3 px-4 py-2.5 border-b', cfg.header)}>
|
||||||
|
<span className={clsx('text-xs font-bold px-2 py-0.5 rounded', cfg.badgeCls)}>
|
||||||
|
{cfg.badge}
|
||||||
|
</span>
|
||||||
|
<span className={clsx('text-sm font-semibold', cfg.accent)}>{cfg.label}</span>
|
||||||
|
{ev && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-600">·</span>
|
||||||
|
<span className="text-sm text-slate-300 font-medium">{ev.name}</span>
|
||||||
|
<span className="text-xs text-slate-500 ml-auto flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
J+{daysSince(refDate, ev.start_date)} depuis {ev.start_date}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!ev && (
|
||||||
|
<span className="text-xs text-slate-600 italic ml-2">Aucun événement actif</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ctx?.has_commentary && (
|
{/* Row body: 4 columns */}
|
||||||
<div className="text-xs text-slate-500 italic border-t border-slate-700/30 pt-2">
|
<div className={clsx('grid grid-cols-4 divide-x divide-slate-700/30', cfg.bg)}>
|
||||||
Commentaires IA disponibles → voir Timeline
|
{/* Pilier 1: Géopolitique */}
|
||||||
</div>
|
<div className="flex flex-col">
|
||||||
)}
|
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||||||
</div>
|
<Globe className="w-3 h-3 text-slate-500" />
|
||||||
)
|
<span className="text-xs text-slate-500 font-medium">Géopolitique</span>
|
||||||
}
|
<Link to={`/timeline?date=${refDate}`} className={clsx('ml-auto text-xs flex items-center gap-0.5 hover:opacity-100 opacity-50', cfg.accent)}>
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
// Pillar 2 — Prix & Marchés
|
</Link>
|
||||||
const KEY_SYMBOLS = ['SPY', 'QQQ', 'GLD', 'USO', 'TLT', 'UUP']
|
|
||||||
|
|
||||||
function QuoteRow({ symbol, quote }: { symbol: string; quote?: Quote }) {
|
|
||||||
if (!quote) return (
|
|
||||||
<div className="flex items-center justify-between py-1">
|
|
||||||
<span className="text-xs text-slate-500">{symbol}</span>
|
|
||||||
<span className="text-xs text-slate-700">—</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
const up = quote.change_pct > 0.1
|
|
||||||
const dn = quote.change_pct < -0.1
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-1">
|
|
||||||
<span className="text-xs text-slate-400">{quote.name || symbol}</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-white tabular-nums">{quote.price?.toFixed(2)}</span>
|
|
||||||
<span className={clsx('text-xs tabular-nums flex items-center gap-0.5', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-500')}>
|
|
||||||
{up ? <TrendingUp className="w-3 h-3" /> : dn ? <TrendingDown className="w-3 h-3" /> : <Minus className="w-3 h-3" />}
|
|
||||||
{quote.change_pct > 0 ? '+' : ''}{quote.change_pct?.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PillarMarkets({ quotes }: { quotes: Record<string, Quote> | null }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-dark-800 rounded-xl border border-blue-700/30 p-5 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<BarChart2 className="w-4 h-4 text-blue-400" />
|
|
||||||
<span className="text-sm font-semibold text-blue-300">Prix & Marchés</span>
|
|
||||||
</div>
|
|
||||||
<Link to="/markets" className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-400 transition-colors">
|
|
||||||
Marchés <ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-slate-700/20">
|
|
||||||
{KEY_SYMBOLS.map(sym => (
|
|
||||||
<QuoteRow key={sym} symbol={sym} quote={quotes?.[sym]} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link to="/specialist-desks" className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-400 transition-colors border-t border-slate-700/30 pt-2">
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
COT & Forward Curves → Specialist Desks
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pillar 3 — Régimes Macro
|
|
||||||
const REGIME_LABELS: Record<string, { label: string; color: string }> = {
|
|
||||||
risk_on: { label: 'Risk On', color: 'text-emerald-400' },
|
|
||||||
risk_off: { label: 'Risk Off', color: 'text-red-400' },
|
|
||||||
stagflation: { label: 'Stagflation', color: 'text-orange-400' },
|
|
||||||
goldilocks: { label: 'Goldilocks', color: 'text-emerald-300' },
|
|
||||||
recession: { label: 'Récession', color: 'text-red-500' },
|
|
||||||
reflation: { label: 'Reflation', color: 'text-yellow-400' },
|
|
||||||
incertain: { label: 'Incertain', color: 'text-slate-400' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function PillarMacro({ macro }: { macro: MacroRegime | null }) {
|
|
||||||
const dominant = macro?.scenarios?.dominant ?? 'incertain'
|
|
||||||
const cfg = REGIME_LABELS[dominant] ?? { label: dominant, color: 'text-slate-400' }
|
|
||||||
const scores = macro?.scenarios?.scores ?? {}
|
|
||||||
const top3 = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 4)
|
|
||||||
|
|
||||||
const KEY_GAUGES = ['vix', 'dxy', 'us10y', 'credit_spread']
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-dark-800 rounded-xl border border-amber-700/30 p-5 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity className="w-4 h-4 text-amber-400" />
|
|
||||||
<span className="text-sm font-semibold text-amber-300">Régimes Macro</span>
|
|
||||||
</div>
|
|
||||||
<Link to="/macro" className="flex items-center gap-1 text-xs text-slate-500 hover:text-amber-400 transition-colors">
|
|
||||||
Macro <ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dominant regime */}
|
|
||||||
<div className="bg-dark-900/40 rounded-lg px-3 py-2 border border-slate-700/20">
|
|
||||||
<div className="text-xs text-slate-500 mb-0.5">Régime dominant</div>
|
|
||||||
<div className={clsx('text-base font-bold', cfg.color)}>{cfg.label}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top scenarios */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{top3.map(([name, score]) => {
|
|
||||||
const r = REGIME_LABELS[name] ?? { label: name, color: 'text-slate-400' }
|
|
||||||
const pct = Math.round((score as number) * 100)
|
|
||||||
return (
|
|
||||||
<div key={name} className="flex items-center gap-2">
|
|
||||||
<span className={clsx('text-xs w-24 truncate', r.color)}>{r.label}</span>
|
|
||||||
<div className="flex-1 bg-dark-700 rounded-full h-1.5">
|
|
||||||
<div className={clsx('h-1.5 rounded-full', pct > 60 ? 'bg-amber-500' : pct > 30 ? 'bg-amber-700' : 'bg-slate-600')}
|
|
||||||
style={{ width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-500 w-8 text-right tabular-nums">{pct}%</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Key gauges */}
|
|
||||||
<div className="grid grid-cols-2 gap-1.5 border-t border-slate-700/30 pt-2">
|
|
||||||
{KEY_GAUGES.map(k => {
|
|
||||||
const g = macro?.gauges?.[k]
|
|
||||||
if (!g) return null
|
|
||||||
const up = (g.change_pct ?? 0) > 0
|
|
||||||
const dn = (g.change_pct ?? 0) < 0
|
|
||||||
return (
|
|
||||||
<div key={k} className="flex items-center justify-between bg-dark-900/30 rounded px-2 py-1">
|
|
||||||
<span className="text-xs text-slate-500">{g.label}</span>
|
|
||||||
<span className={clsx('text-xs font-medium tabular-nums', up ? 'text-emerald-400' : dn ? 'text-red-400' : 'text-slate-400')}>
|
|
||||||
{g.value?.toFixed(g.value > 10 ? 1 : 2) ?? '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pillar 4 — Calendrier Économique
|
|
||||||
function PillarCalendar({ events }: { events: CalEvent[] | null }) {
|
|
||||||
const upcoming = (events ?? [])
|
|
||||||
.filter(e => e.date >= todayStr())
|
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
.slice(0, 5)
|
|
||||||
|
|
||||||
const recent = (events ?? [])
|
|
||||||
.filter(e => e.date < todayStr())
|
|
||||||
.sort((a, b) => b.date.localeCompare(a.date))
|
|
||||||
.slice(0, 3)
|
|
||||||
|
|
||||||
const importanceColor = (n: number) =>
|
|
||||||
n >= 3 ? 'text-red-400' : n >= 2 ? 'text-yellow-400' : 'text-slate-500'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-dark-800 rounded-xl border border-emerald-700/30 p-5 flex flex-col gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-emerald-400" />
|
|
||||||
<span className="text-sm font-semibold text-emerald-300">Calendrier Économique</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link to="/institutional" className="flex items-center gap-1 text-xs text-slate-500 hover:text-emerald-400 transition-colors">
|
|
||||||
Rapports <ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
<Link to="/calendar" className="flex items-center gap-1 text-xs text-slate-500 hover:text-emerald-400 transition-colors">
|
|
||||||
Calendrier <ArrowRight className="w-3 h-3" />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upcoming */}
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1.5">À venir</div>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{upcoming.length === 0 && <div className="text-xs text-slate-600 italic">Aucun événement</div>}
|
|
||||||
{upcoming.map((ev, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-2">
|
|
||||||
<div className="flex items-center gap-1 shrink-0 w-20">
|
|
||||||
<Clock className="w-3 h-3 text-slate-600" />
|
|
||||||
<span className="text-xs text-slate-500">{fmtDate(ev.date)}</span>
|
|
||||||
</div>
|
|
||||||
<span className={clsx('text-xs font-medium flex-1 truncate', importanceColor(ev.importance))}>
|
|
||||||
{ev.currency && <span className="text-slate-600 mr-1">[{ev.currency}]</span>}
|
|
||||||
{ev.title}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-0.5 shrink-0">
|
|
||||||
{[1, 2, 3].map(n => (
|
|
||||||
<div key={n} className={clsx('w-1 h-1 rounded-full', n <= ev.importance ? importanceColor(ev.importance).replace('text-', 'bg-') : 'bg-slate-700')} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent */}
|
|
||||||
{recent.length > 0 && (
|
|
||||||
<div className="border-t border-slate-700/30 pt-2">
|
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wider mb-1.5">Récents</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{recent.map((ev, i) => (
|
|
||||||
<div key={i} className="flex items-center gap-2 opacity-60">
|
|
||||||
<span className="text-xs text-slate-600 w-20 shrink-0">{fmtDate(ev.date)}</span>
|
|
||||||
<span className="text-xs text-slate-500 truncate">
|
|
||||||
{ev.currency && <span className="mr-1">[{ev.currency}]</span>}
|
|
||||||
{ev.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<CellGeo ev={ev} level={level} refDate={refDate} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Pilier 2: Prix & Marchés */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||||||
|
<BarChart2 className="w-3 h-3 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-500 font-medium">Prix & Marchés</span>
|
||||||
|
<Link to="/markets" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CellMarkets ev={ev} level={level} quotes={quotes} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pilier 3: Régimes Macro */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||||||
|
<Activity className="w-3 h-3 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-500 font-medium">Régimes Macro</span>
|
||||||
|
<Link to="/macro" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CellMacro level={level} macro={macro} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pilier 4: Calendrier */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-slate-700/20">
|
||||||
|
<Calendar className="w-3 h-3 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-500 font-medium">Calendrier</span>
|
||||||
|
<Link to="/calendar" className="ml-auto text-xs text-slate-600 hover:text-slate-400 flex items-center gap-0.5">
|
||||||
|
<ArrowRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<CellCalendar level={level} events={calEvents} refDate={refDate} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ─────────────────────────────────────────────────────────────
|
// ── Main ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ExternalSnapshot() {
|
export default function ExternalSnapshot() {
|
||||||
const [refDate, setRefDate] = useState(todayStr())
|
const [refDate, setRefDate] = useState(todayStr())
|
||||||
@@ -322,25 +384,18 @@ export default function ExternalSnapshot() {
|
|||||||
const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null)
|
const [quotes, setQuotes] = useState<Record<string, Quote> | null>(null)
|
||||||
const [macro, setMacro] = useState<MacroRegime | null>(null)
|
const [macro, setMacro] = useState<MacroRegime | null>(null)
|
||||||
const [calEvents, setCalEvents] = useState<CalEvent[] | null>(null)
|
const [calEvents, setCalEvents] = useState<CalEvent[] | null>(null)
|
||||||
const [loadingCtx, setLoadingCtx] = useState(false)
|
|
||||||
|
|
||||||
// Fetch temporal context when date changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingCtx(true)
|
|
||||||
api.get(`/timeline/day/${refDate}`)
|
api.get(`/timeline/day/${refDate}`)
|
||||||
.then(r => setCtx(r.data))
|
.then(r => setCtx(r.data))
|
||||||
.catch(() => {})
|
.catch(() => setCtx(null))
|
||||||
.finally(() => setLoadingCtx(false))
|
|
||||||
}, [refDate])
|
}, [refDate])
|
||||||
|
|
||||||
// Fetch live data once (always current)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/market/quotes').then(r => {
|
api.get('/market/quotes').then(r => {
|
||||||
// Flatten: quotes is Record<assetClass, Quote[]> → flatten to Record<symbol, Quote>
|
|
||||||
const flat: Record<string, Quote> = {}
|
const flat: Record<string, Quote> = {}
|
||||||
for (const arr of Object.values(r.data as Record<string, Quote[]>)) {
|
for (const arr of Object.values(r.data as Record<string, Quote[]>))
|
||||||
for (const q of arr) flat[q.symbol] = q
|
for (const q of arr) flat[q.symbol] = q
|
||||||
}
|
|
||||||
setQuotes(flat)
|
setQuotes(flat)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
@@ -353,70 +408,66 @@ export default function ExternalSnapshot() {
|
|||||||
if (next <= todayStr()) setRefDate(next)
|
if (next <= todayStr()) setRefDate(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const evLong = ctx?.events?.long ?? null
|
||||||
<div className="p-6 space-y-5 max-w-6xl mx-auto">
|
const evMedium = ctx?.events?.medium ?? null
|
||||||
{/* Header */}
|
const evShort = ctx?.events?.short ?? null
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
|
||||||
<Globe className="w-5 h-5 text-slate-400" />
|
|
||||||
Snapshot Externe
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-slate-500 mt-0.5">
|
|
||||||
Vue d'ensemble des 4 piliers du contexte externe — {fmtDate(refDate)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date navigator */}
|
return (
|
||||||
<div className="bg-dark-800 rounded-xl border border-slate-700/40 p-3">
|
<div className="p-6 space-y-4 max-w-7xl mx-auto">
|
||||||
<div className="flex items-center gap-3">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center justify-between">
|
||||||
<button onClick={() => navigate(-30)} className="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1M</button>
|
<div>
|
||||||
<button onClick={() => navigate(-7)} className="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-7J</button>
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
<button onClick={() => navigate(-1)} className="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1J</button>
|
<Globe className="w-5 h-5 text-slate-400" />
|
||||||
</div>
|
Snapshot Externe
|
||||||
<div className="flex-1 flex items-center justify-center gap-3">
|
</h1>
|
||||||
<input
|
<p className="text-sm text-slate-500 mt-0.5">
|
||||||
type="date"
|
3 temporalités × 4 piliers — vision situationnelle complète
|
||||||
value={refDate}
|
</p>
|
||||||
min="2020-01-01"
|
</div>
|
||||||
max={todayStr()}
|
<div className="flex items-center gap-2">
|
||||||
onChange={e => setRefDate(e.target.value)}
|
<button onClick={() => navigate(-30)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1M</button>
|
||||||
className="bg-dark-700 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500"
|
<button onClick={() => navigate(-7)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-7J</button>
|
||||||
/>
|
<button onClick={() => navigate(-1)} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">-1J</button>
|
||||||
<button onClick={() => setRefDate(todayStr())} className="text-xs text-slate-400 hover:text-white">
|
<input
|
||||||
Aujourd'hui
|
type="date"
|
||||||
</button>
|
value={refDate}
|
||||||
</div>
|
min="2020-01-01"
|
||||||
<div className="text-xs text-slate-500">
|
max={todayStr()}
|
||||||
{refDate < todayStr() && (
|
onChange={e => setRefDate(e.target.value)}
|
||||||
<span className="text-amber-500/70">Piliers 2-3-4 en données live</span>
|
className="bg-dark-800 border border-slate-700/40 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-slate-500"
|
||||||
)}
|
/>
|
||||||
</div>
|
<button onClick={() => setRefDate(todayStr())} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded">
|
||||||
|
Aujourd'hui
|
||||||
|
</button>
|
||||||
|
<button onClick={() => navigate(1)} disabled={refDate >= todayStr()} className="px-2 py-1.5 text-xs text-slate-400 hover:text-white hover:bg-dark-700 rounded disabled:opacity-20">+1J</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 4 pillar grid */}
|
{/* Column headers */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-4 gap-0 text-xs text-slate-600 uppercase tracking-wider px-1">
|
||||||
<PillarGeo ctx={loadingCtx ? null : ctx} refDate={refDate} />
|
<div className="flex items-center gap-1.5"><Globe className="w-3 h-3" /> Géopolitique</div>
|
||||||
<PillarMarkets quotes={quotes} />
|
<div className="flex items-center gap-1.5"><BarChart2 className="w-3 h-3" /> Prix & Marchés</div>
|
||||||
<PillarMacro macro={macro} />
|
<div className="flex items-center gap-1.5"><Activity className="w-3 h-3" /> Régimes Macro</div>
|
||||||
<PillarCalendar events={calEvents} />
|
<div className="flex items-center gap-1.5"><Calendar className="w-3 h-3" /> Calendrier</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links bar */}
|
{/* 3 temporal rows */}
|
||||||
<div className="flex items-center gap-4 text-xs text-slate-600 border-t border-slate-700/30 pt-4">
|
<TemporalRow level="long" ev={evLong} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
|
||||||
<span>Navigation rapide :</span>
|
<TemporalRow level="medium" ev={evMedium} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
|
||||||
|
<TemporalRow level="short" ev={evShort} quotes={quotes} macro={macro} calEvents={calEvents} refDate={refDate} />
|
||||||
|
|
||||||
|
{/* Footer links */}
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-600 pt-2">
|
||||||
|
<span>Voir aussi :</span>
|
||||||
{[
|
{[
|
||||||
{ to: '/timeline', label: 'Timeline' },
|
['/timeline', 'Timeline'],
|
||||||
{ to: '/geo', label: 'Geo Radar' },
|
['/specialist-desks','COT & Curves'],
|
||||||
{ to: '/markets', label: 'Marchés' },
|
['/geo', 'Geo Radar'],
|
||||||
{ to: '/macro', label: 'Macro' },
|
['/institutional', 'Rapports'],
|
||||||
{ to: '/calendar', label: 'Calendrier' },
|
].map(([to, label]) => (
|
||||||
{ to: '/institutional', label: 'Rapports' },
|
<Link key={to} to={to} className="hover:text-slate-300 flex items-center gap-1">
|
||||||
{ to: '/specialist-desks', label: 'Desks' },
|
{label} <ArrowRight className="w-3 h-3" />
|
||||||
].map(({ to, label }) => (
|
|
||||||
<Link key={to} to={to} className="hover:text-slate-300 transition-colors">
|
|
||||||
{label}
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user