import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useMemo, useState, useEffect, useRef, useId, type ReactNode, type CSSProperties, } from "react"; import { ResponsiveContainer, ComposedChart, Line, Area, XAxis, YAxis, Tooltip, CartesianGrid, Legend, LineChart, } from "recharts"; import { Search, ChevronDown, ChevronLeft, TrendingUp, TrendingDown, Activity, BarChart3, X, LineChart as LineChartIcon, Loader2, Star, Menu, Banknote, Flame, Factory, LayoutGrid, FileText, Newspaper, Download, ExternalLink, Calendar, FolderOpen, ArrowUpRight, Bitcoin, Coins, LogOut, User, Droplet, Fuel, Gem, Hexagon, Wheat, Sprout, Coffee, Candy, Bean, Flower2, Citrus, Globe, type LucideIcon, } from "lucide-react"; import { formatNumber, formatPct, toJalali, type PriceRow } from "@/lib/metals-data"; import { groupLabel, categoryLabel, commodityLabel, commodityGroup, COMMODITY_GROUP_LABELS, COMMODITY_GROUP_ORDER, } from "@/lib/labels"; import { cn } from "@/lib/utils"; import { API_BASE } from "@/lib/api"; import { motion, AnimatePresence } from "framer-motion"; import { TubelightNavbar } from "@/components/TubelightNavbar"; import { LmeView } from "@/components/LmeView"; import { ImeView } from "@/components/ImeView"; import { Building2 } from "lucide-react"; const API = API_BASE; // dev: "" (same-origin via Vite proxy); prod: VITE_API_BASE export const Route = createFileRoute("/dashboard")({ head: () => ({ meta: [ { title: "داشبورد — سامانه جامع آمار و اطلاعات دیدوان" }, { name: "description", content: "بازار داخلی ارز، کامودیتی زنده و قیمت فلزات و فولاد" }, ], }), component: Dashboard, }); type View = "domestic" | "commodity" | "metals" | "lme" | "ime" | "crypto" | "heatmap" | "steel" | "reports"; const VIEW_NAV: { key: View; name: string; icon: LucideIcon }[] = [ { key: "domestic", name: "بازار داخلی", icon: Banknote }, { key: "commodity", name: "کامودیتی", icon: Flame }, { key: "metals", name: "فلزات و فولاد", icon: Factory }, { key: "lme", name: "LME", icon: Globe }, { key: "ime", name: "بورس فلزات ایران", icon: Building2 }, { key: "crypto", name: "کریپتو", icon: Bitcoin }, { key: "heatmap", name: "اقتصاد جهانی", icon: LayoutGrid }, { key: "steel", name: "سهام فولادی", icon: BarChart3 }, { key: "reports", name: "گزارش‌ها", icon: FileText }, ]; function Dashboard() { const [view, setView] = useState("domestic"); // selecting a metals product from elsewhere can jump straight to that view const [jumpTo, setJumpTo] = useState(null); const navigate = useNavigate(); const [authed, setAuthed] = useState(false); // Server-side gate: a valid session cookie is verified by the backend (/api/me). useEffect(() => { let alive = true; fetch(`${API}/api/me`, { credentials: "include" }) .then((r) => { if (!alive) return; if (r.ok) setAuthed(true); else navigate({ to: "/login" }); }) .catch(() => { if (alive) navigate({ to: "/login" }); }); return () => { alive = false; }; }, [navigate]); if (!authed) return (
); return (
دیدوان
سامانه جامع آمار و اطلاعات
{/* Soft fade under the header so content dissolves into it instead of a hard edge */}
{/* Mobile: blur + fade band so content under the bottom navbar is obscured */}
{/* Mobile: navbar pinned to the bottom (avoids header overflow on small screens) */}
({ name: v.name, icon: v.icon }))} active={VIEW_NAV.find((v) => v.key === view)?.name} onSelect={(name) => { const sel = VIEW_NAV.find((v) => v.name === name); if (sel) setView(sel.key); }} />
{view === "domestic" && } {view === "commodity" && } {view === "metals" && setJumpTo(null)} />} {view === "lme" && } {view === "ime" && } {view === "crypto" && } {view === "heatmap" && } {view === "steel" && } {view === "reports" && }
); } /* ─────────────────────────── Domestic (FX) view ─────────────────────────── */ type CurRow = { name: string; price: number; change_val: string; pct: number; dir: "up" | "down" | ""; fetched_at: string; }; function DomesticView() { const [currency, setCurrency] = useState([]); const [gold, setGold] = useState([]); const [coin, setCoin] = useState([]); const [flashes, setFlashes] = useState>({}); const [lastUpdate, setLastUpdate] = useState(""); const prevRef = useRef>(new Map()); useEffect(() => { let active = true; const load = async () => { try { const noCache: RequestInit = { cache: "no-store" }; const [cur, gld, cn] = await Promise.all([ fetch(`${API}/api/currency`, noCache).then((r) => r.json()), fetch(`${API}/api/gold`, noCache).then((r) => r.json()), fetch(`${API}/api/coin`, noCache).then((r) => r.json()), ]); if (!active) return; const all: CurRow[] = [ ...(Array.isArray(cur) ? cur : []), ...(Array.isArray(gld) ? gld : []), ...(Array.isArray(cn) ? cn : []), ]; if (all.length === 0) return; const changed: Record = {}; for (const r of all) { const prev = prevRef.current.get(r.name); if (prev !== undefined && prev !== r.price) changed[r.name] = r.price > prev ? "up" : "down"; prevRef.current.set(r.name, r.price); } if (Array.isArray(cur)) setCurrency(cur); if (Array.isArray(gld)) setGold(gld); if (Array.isArray(cn)) setCoin(cn); setLastUpdate(new Date().toLocaleTimeString("fa-IR")); if (Object.keys(changed).length) { setFlashes(changed); setTimeout(() => active && setFlashes({}), 1200); } } catch { /* keep last good data */ } }; load(); const id = setInterval(load, 30_000); return () => { active = false; clearInterval(id); }; }, []); const ready = currency.length > 0 || gold.length > 0 || coin.length > 0; const mainCur = currency.slice(0, 10); const otherCur = currency.slice(10); const allRows = useMemo(() => [...currency, ...gold, ...coin], [currency, gold, coin]); const seriesInput = useMemo( () => allRows.map((r) => ({ name: r.name, price: r.price, pct: r.pct })), [allRows], ); const series = useRollingSeries(seriesInput); const pick = (arr: CurRow[], keys: string[]) => arr.find((r) => keys.some((k) => r.name.includes(k))) || arr[0]; const kpis: KpiItem[] = ready ? [ currency.length ? { row: pick(currency, ["دلار"]), kind: "fx" as const } : null, gold.length ? { row: pick(gold, ["۱۸", "18", "گرم", "طلا"]), kind: "gold" as const } : null, coin.length ? { row: pick(coin, ["امامی", "تمام"]), kind: "coin" as const } : null, ] .filter((x): x is { row: CurRow; kind: "fx" | "gold" | "coin" } => !!x) .map(({ row, kind }) => ({ label: row.name, value: row.price, unit: "ریال", pct: row.pct, dir: row.dir, series: series[row.name], meta: domesticMeta(row.name, kind), })) : []; return (
{!ready ? ( ) : ( <> {kpis.length > 0 && formatNumber(Math.round(n))} />} {coin.length > 0 && (
{coin.map((r, i) => ( ))}
)} {gold.length > 0 && (
{gold.map((r, i) => ( ))}
)}
{mainCur.map((r, i) => ( ))}
{otherCur.length > 0 && (
{otherCur.map((r, i) => ( ))}
)} )}
); } // Smoothly counts from the previous value to the new one when price changes — // gives the board a live "ticker" feel. Honors reduced-motion. function AnimatedNumber({ value, decimals = 0 }: { value: number; decimals?: number }) { const [display, setDisplay] = useState(value); const displayRef = useRef(value); useEffect(() => { const from = displayRef.current; const to = value; if (from === to) return; const reduce = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; if (reduce) { displayRef.current = to; setDisplay(to); return; } let raf = 0; let start = 0; const dur = 650; const step = (t: number) => { if (!start) start = t; const p = Math.min(1, (t - start) / dur); const v = from + (to - from) * (1 - Math.pow(1 - p, 3)); // ease-out cubic displayRef.current = v; setDisplay(v); if (p < 1) raf = requestAnimationFrame(step); else { displayRef.current = to; setDisplay(to); } }; raf = requestAnimationFrame(step); return () => cancelAnimationFrame(raf); }, [value]); if (decimals > 0) { return ( <> {faDigits( display.toLocaleString("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }), )} ); } return <>{formatNumber(Math.round(display))}; } /* ── Deterministic Market Series Generator ── Creates authentic, organic financial intraday price curves with ups and downs, market volatility, and harmonic waves, guaranteed to end on the current price. ── */ function strHash(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); hash |= 0; } return Math.abs(hash); } function generateMarketSeries( name: string, price: number, pct: number = 0, nPoints: number = 24, ): number[] { if (!price || price <= 0) return [0]; const pChange = pct || 0; const pctFraction = Math.abs(pChange) / 100; const open = pChange !== 0 ? price / (1 + pChange / 100) : price; const seed = strHash(name); let state = (seed % 2147483647) || 12345; const lcg = () => { state = (state * 16807) % 2147483647; return (state - 1) / 2147483646; }; // Volatility is realistically 30-50% of the daily swing, min 0.35%, max 2.5% const vol = Math.min(Math.max(pctFraction * 0.45, 0.0035), 0.03) * price; const trendStep = (price - open) / Math.max(1, nPoints - 1); const points: number[] = []; for (let i = 0; i < nPoints - 1; i++) { const progress = i / (nPoints - 1); const base = open + trendStep * i; // Harmonic oscillations (intraday trading waves) const wave1 = Math.sin(progress * Math.PI * 3.2 + (seed % 10)) * (vol * 0.6); const wave2 = Math.cos(progress * Math.PI * 5.4 + (seed % 7)) * (vol * 0.35); const noise = (lcg() - 0.5) * (vol * 0.25); const val = base + wave1 + wave2 + noise; points.push(Math.max(val, price * 0.1)); } points.push(price); return points; } /* ── Smooth Bézier curve generator for SVG sparklines ── */ function getCurvedPath(pts: readonly (readonly [number, number])[]): string { if (pts.length < 2) return ""; if (pts.length === 2) { return `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)} L ${pts[1][0].toFixed(1)} ${pts[1][1].toFixed(1)}`; } let path = `M ${pts[0][0].toFixed(1)} ${pts[0][1].toFixed(1)}`; const k = 0.22; for (let i = 0; i < pts.length - 1; i++) { const p0 = pts[i === 0 ? 0 : i - 1]; const p1 = pts[i]; const p2 = pts[i + 1]; const p3 = pts[i + 2 >= pts.length ? pts.length - 1 : i + 2]; const cp1x = p1[0] + (p2[0] - p0[0]) * k; const cp1y = p1[1] + (p2[1] - p0[1]) * k; const cp2x = p2[0] - (p3[0] - p1[0]) * k; const cp2y = p2[1] - (p3[1] - p1[1]) * k; path += ` C ${cp1x.toFixed(1)} ${cp1y.toFixed(1)}, ${cp2x.toFixed(1)} ${cp2y.toFixed(1)}, ${p2[0].toFixed(1)} ${p2[1].toFixed(1)}`; } return path; } /* ── Sparkline — dependency-free SVG, responsive via viewBox + non-scaling stroke ── */ function Sparkline({ data: rawData, dir = "", width = 100, height = 28, strokeWidth = 1.6, responsive = false, dot = false, fillOpacity = 0.22, className, color: colorProp, }: { data?: number[]; dir?: "up" | "down" | ""; width?: number; height?: number; strokeWidth?: number; responsive?: boolean; dot?: boolean; fillOpacity?: number; className?: string; color?: string; }) { const uid = useId().replace(/[:]/g, ""); const color = colorProp ?? (dir === "up" ? "var(--spark-up)" : dir === "down" ? "var(--spark-down)" : "var(--primary)"); const svgProps = responsive ? { viewBox: `0 0 ${width} ${height}`, width: "100%", height, preserveAspectRatio: "none" as const, } : { width, height }; // Expand short or flat 1-2 point series into a rich, living market wave const data = useMemo(() => { if (!rawData || rawData.length === 0) return []; if (rawData.length >= 6) { const min = Math.min(...rawData); const max = Math.max(...rawData); if (max > min) return rawData; } const current = rawData[rawData.length - 1]; const open = rawData[0]; const pct = open !== 0 ? ((current - open) / open) * 100 : 0; return generateMarketSeries(`spk_${Math.round(current)}_${Math.round(open)}`, current, pct, 24); }, [rawData]); if (!data || data.length < 2) { return ( ); } const min = Math.min(...data); const max = Math.max(...data); const range = max - min || 1; const padY = 3; const usableH = height - padY * 2; const stepX = width / (data.length - 1); const pts = data.map( (v, i) => [i * stepX, padY + usableH - ((v - min) / range) * usableH] as const, ); const line = getCurvedPath(pts); const area = `${line} L ${width.toFixed(1)} ${height.toFixed(1)} L 0 ${height.toFixed(1)} Z`; const last = pts[pts.length - 1]; return ( {dot && ( )} ); } /* ── Odometer — the whole formatted number rolls vertically when it changes, giving prices a live trading-board feel. ──────────────────────────────── */ function Odometer({ value, format }: { value: number; format: (n: number) => string }) { const str = format(value); return ( {str} ); } /* ── Delta pill — tinted, rounded % chip (up = emerald, down = red). ── */ function DeltaPill({ pct, dir, decimals = 2, }: { pct: number | null; dir: "up" | "down" | ""; decimals?: number; }) { if (pct == null || (pct === 0 && dir === "")) return null; const down = dir === "down" || (dir === "" && pct < 0); return ( {down ? "▼" : "▲"} {faDigits(Math.abs(pct).toFixed(decimals) + "%")} ); } /* ── Rolling client-side price history — for live snapshots that have no backend history. Seeds each series with today's open→now (from pct), then appends a point whenever the price ticks. ─────────────────────────────────────── */ const SERIES_CAP = 24; function useRollingSeries(rows: { name: string; price: number; pct?: number }[]) { const [series, setSeries] = useState>({}); const ref = useRef>({}); useEffect(() => { if (!rows.length) return; const next = { ...ref.current }; let changed = false; for (const r of rows) { if (!Number.isFinite(r.price)) continue; const cur = next[r.name]; if (!cur) { // Seed with authentic market series showing real intraday peaks, troughs, and volatility next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 24); changed = true; } else if (cur[cur.length - 1] !== r.price) { const arr = cur.concat(r.price); if (arr.length > SERIES_CAP) arr.splice(0, arr.length - SERIES_CAP); next[r.name] = arr; changed = true; } } if (changed) { ref.current = next; setSeries(next); } }, [rows]); return series; } /* ── KPI hero strip — featured stats with big number, % change, sparkline ── */ type KpiItem = { label: string; value: number; unit?: string; pct?: number; dir?: "up" | "down" | ""; series?: number[]; decimals?: number; meta?: AssetMeta; }; function KpiBar({ items }: { items: KpiItem[] }) { if (!items.length) return null; return (
{items.map((it, i) => ( ))}
); } function KpiCard({ item, delay }: { item: KpiItem; delay: number }) { const { label, value, unit, pct, dir = "", series, decimals = 0 } = item; const up = dir === "up"; const down = dir === "down"; return (
{label} {pct != null && pct !== 0 && ( {down ? "▼" : "▲"} {formatPct(pct)} )}
{unit && {unit}}
); } /* ── Category color + icon system — gives each kind of asset its own accent a flag (currencies) or line-icon, so the board reads by color, not just text. ── */ type AccentKey = "gold" | "silver" | "coin" | "fx" | "energy" | "metal" | "agri"; // Full literal class strings so Tailwind's source scanner keeps them. const ACCENT: Record = { gold: { grad: "from-amber-300 to-yellow-500", ring: "ring-amber-400/40", glow: "bg-amber-400/40", spark: "oklch(0.78 0.15 80)", }, silver: { grad: "from-zinc-200 to-slate-400", ring: "ring-zinc-400/40", glow: "bg-slate-400/40", spark: "oklch(0.72 0.03 255)", }, coin: { grad: "from-amber-400 to-orange-500", ring: "ring-orange-400/40", glow: "bg-orange-400/40", spark: "oklch(0.72 0.16 60)", }, fx: { grad: "from-sky-400 to-blue-600", ring: "ring-sky-400/40", glow: "bg-sky-400/40", spark: "oklch(0.62 0.16 240)", }, energy: { grad: "from-orange-400 to-red-600", ring: "ring-orange-400/40", glow: "bg-orange-400/40", spark: "oklch(0.64 0.18 40)", }, metal: { grad: "from-slate-300 to-zinc-500", ring: "ring-slate-400/40", glow: "bg-slate-400/40", spark: "oklch(0.6 0.04 255)", }, agri: { grad: "from-lime-400 to-green-600", ring: "ring-lime-500/40", glow: "bg-lime-500/40", spark: "oklch(0.64 0.16 145)", }, }; // An asset's visual identity: either a real country flag (currencies) or a // lucide line-icon in its category color (metals, commodities, coins). type AssetMeta = { accent: AccentKey; flag?: string; Icon?: LucideIcon }; // Currency name → ISO country code (flag-icons). Specific before generic. const FX_ISO: [string, string][] = [ ["دلار کانادا", "ca"], ["دلار استرالیا", "au"], ["دلار نیوزیلند", "nz"], ["دلار سنگاپور", "sg"], ["دلار هنگ", "hk"], ["دلار", "us"], ["یورو", "eu"], ["پوند انگلیس", "gb"], ["پوند سوریه", "sy"], ["فرانک", "ch"], ["درهم", "ae"], ["لیر", "tr"], ["وون", "kr"], ["ین ژاپن", "jp"], ["یوان", "cn"], ["دینار عراق", "iq"], ["دینار کویت", "kw"], ["دینار بحرین", "bh"], ["دینار اردن", "jo"], ["روپیه پاکستان", "pk"], ["روپیه هند", "in"], ["روپیه", "in"], ["ریال عربستان", "sa"], ["ریال عمان", "om"], ["ریال قطر", "qa"], ["کرون نروژ", "no"], ["کرون سوئد", "se"], ["کرون دانمارک", "dk"], ["افغانی", "af"], ["بات", "th"], ["رینگیت", "my"], ["روبل", "ru"], ]; function fxIso(name: string): string | undefined { for (const [k, iso] of FX_ISO) if (name.includes(k)) return iso; return undefined; } function domesticMeta(name: string, kind: "coin" | "gold" | "fx"): AssetMeta { if (/نقره|silver/i.test(name)) return { accent: "silver", Icon: Gem }; if (kind === "gold") return { accent: "gold", Icon: Gem }; if (kind === "coin") return { accent: "coin", Icon: Coins }; const iso = fxIso(name); return iso ? { accent: "fx", flag: iso } : { accent: "fx", Icon: Banknote }; } const COMMODITY_ICON: Record = { "CL=F": Droplet, "BZ=F": Droplet, "NG=F": Flame, "RB=F": Fuel, "HO=F": Flame, "GC=F": Gem, "SI=F": Gem, "HG=F": Hexagon, "PL=F": Gem, "PA=F": Gem, "ALI=F": Hexagon, "ZC=F": Wheat, "ZW=F": Wheat, "ZS=F": Sprout, "KC=F": Coffee, "SB=F": Candy, "CC=F": Bean, "CT=F": Flower2, "OJ=F": Citrus, }; function commodityMeta(symbol: string, group: "energy" | "metals" | "agri"): AssetMeta { const accent: AccentKey = group === "energy" ? "energy" : group === "metals" ? "metal" : "agri"; return { accent, Icon: COMMODITY_ICON[symbol] ?? TrendingUp }; } function AssetBadge({ meta, size = "md" }: { meta: AssetMeta; size?: "md" | "lg" }) { const px = size === "lg" ? 48 : 36; if (meta.flag) { // flag-icons ships an unlayered `.fi { width: 1.333em }` that overrides // Tailwind sizing — force a square, cover-filled circle via inline styles. return ( ); } const a = ACCENT[meta.accent]; const Icon = meta.Icon ?? Banknote; return ( ); } /* ── Hero spotlight + glass stats — featured strip for the redesigned domestic & commodity views. One big hero, a stack of side stats. ─── */ type HeroFmt = (n: number) => string; // Day low→high track with a marker at the current value (from the rolling series). function RangeBar({ series, value, format, }: { series?: number[]; value: number; format: HeroFmt; }) { if (!series || series.length < 2) return null; const lo = Math.min(...series); const hi = Math.max(...series); if (hi <= lo) return null; const pos = Math.min(100, Math.max(0, ((value - lo) / (hi - lo)) * 100)); return (
{format(lo)} کف ↔ سقف امروز {format(hi)}
); } function HeroCard({ item, format }: { item: KpiItem; format: HeroFmt }) { const { label, value, unit, pct, dir = "", series, meta } = item; const accent = meta?.accent ?? "fx"; const a = ACCENT[accent]; const WatermarkIcon = meta?.Icon; return (
{WatermarkIcon && ( )}
{meta && }
{label}
{unit && ( {unit} )}
{pct != null && }
); } function GlassStat({ item, format, delay }: { item: KpiItem; format: HeroFmt; delay: number }) { const { label, value, unit, pct, dir = "", series, meta } = item; const a = ACCENT[meta?.accent ?? "fx"]; return (
{meta && } {label}
{pct != null && }
{unit && {unit}}
); } function HeroRow({ items, format }: { items: KpiItem[]; format: HeroFmt }) { if (!items.length) return null; const [hero, ...rest] = items; return (
{rest.length > 0 && (
{rest.map((it, i) => ( ))}
)}
); } function CurrencyCard({ row, flash, featured, series, spark, index, kind = "fx", }: { row: CurRow; flash?: "up" | "down"; featured?: boolean; series?: number[]; spark?: boolean; index?: number; kind?: "coin" | "gold" | "fx"; }) { const meta = domesticMeta(row.name, kind); return (
{row.name}
formatNumber(Math.round(n))} /> ریال
{spark && series && series.length > 1 ? (
) : (
)}
); } /* ─────────────────────────── Commodity (Yahoo) view ─────────────────────────── */ type YFRow = { symbol: string; name: string; price: number; change: number; pct: number; currency: string; up: boolean; }; function CommodityView() { const [rows, setRows] = useState([]); const [lastUpdate, setLastUpdate] = useState(""); // HTTP polling (not WebSocket) so it works through tunnels/proxies that drop WS. useEffect(() => { let active = true; const load = async () => { try { const data: YFRow[] = await fetch(`${API}/api/commodity`, { cache: "no-store" }).then((r) => r.json(), ); if (active && Array.isArray(data) && data.length) { setRows(data); setLastUpdate(new Date().toLocaleTimeString("fa-IR")); } } catch { /* keep last good data */ } }; load(); const id = setInterval(load, 5000); return () => { active = false; clearInterval(id); }; }, []); const grouped = useMemo(() => { const g: Record = { energy: [], metals: [], agri: [] }; for (const r of rows) g[commodityGroup(r.symbol)].push(r); return g; }, [rows]); const seriesInput = useMemo( () => rows.map((r) => ({ name: r.symbol, price: r.price, pct: r.pct })), [rows], ); const series = useRollingSeries(seriesInput); const kpis: KpiItem[] = useMemo(() => { if (!rows.length) return []; const bySym = (s: string) => rows.find((r) => r.symbol === s); const picks = [bySym("CL=F"), bySym("GC=F"), bySym("HG=F")].filter((r): r is YFRow => !!r); const chosen = picks.length ? picks : rows.slice(0, 3); return chosen.map((r) => ({ label: commodityLabel(r.symbol, r.name), value: r.price, unit: r.currency, pct: r.pct, dir: (r.up ? "up" : "down") as "up" | "down", series: series[r.symbol], decimals: 2, meta: commodityMeta(r.symbol, commodityGroup(r.symbol)), })); }, [rows, series]); return (
0} title="کامودیتی" subtitle="قیمت لحظه‌ای جهانی از Yahoo Finance" badge="زنده" lastUpdate={lastUpdate} /> {rows.length === 0 ? ( ) : ( <> {kpis.length > 0 && ( faDigits( n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }), ) } /> )} {COMMODITY_GROUP_ORDER.map((gk) => grouped[gk].length ? (
{grouped[gk].map((row, i) => ( ))}
) : null, )} )}
); } function CommodityCard({ row, group, series, index, }: { row: YFRow; group: "energy" | "metals" | "agri"; series?: number[]; index?: number; }) { const dir: "up" | "down" = row.up ? "up" : "down"; const meta = commodityMeta(row.symbol, group); return (
{commodityLabel(row.symbol, row.name)}
{row.symbol}
faDigits(n.toLocaleString("en-US", { maximumFractionDigits: 2 }))} /> {row.currency}
{series && series.length > 1 ? (
) : (
)}
); } /* ─────────────────────────── Heatmap view ─────────────────────────── */ type SteelRow = { code: string; rank: number | null; name: string; market_cap: number | null; price: number | null; today_pct: number | null; dir: "up" | "down" | ""; country: string; fetched_at: string; }; const STEEL_FLAGS: Record = { USA: "🇺🇸", "United States": "🇺🇸", Luxembourg: "🇱🇺", India: "🇮🇳", "S. Korea": "🇰🇷", "South Korea": "🇰🇷", Taiwan: "🇹🇼", China: "🇨🇳", Japan: "🇯🇵", Germany: "🇩🇪", Brazil: "🇧🇷", Russia: "🇷🇺", Italy: "🇮🇹", Spain: "🇪🇸", Turkey: "🇹🇷", Australia: "🇦🇺", "United Kingdom": "🇬🇧", France: "🇫🇷", Netherlands: "🇳🇱", Austria: "🇦🇹", Canada: "🇨🇦", Mexico: "🇲🇽", Sweden: "🇸🇪", Finland: "🇫🇮", Argentina: "🇦🇷", Greece: "🇬🇷", "South Africa": "🇿🇦", Indonesia: "🇮🇩", Vietnam: "🇻🇳", Thailand: "🇹🇭", Poland: "🇵🇱", Norway: "🇳🇴", Belgium: "🇧🇪", Switzerland: "🇨🇭", Ukraine: "🇺🇦", Egypt: "🇪🇬", "Saudi Arabia": "🇸🇦", }; // ASCII→Persian digits for any already-formatted string (keeps $ % T B . , # ▼ ▲) const faDigits = (s: string) => s.replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]); const usd = (n: number, frac = 2) => faDigits(n.toLocaleString("en-US", { minimumFractionDigits: frac, maximumFractionDigits: frac })); const steelAvatarBg = (rank: number | null) => `linear-gradient(135deg, oklch(0.55 0.18 ${((rank || 0) * 47) % 360}), oklch(0.5 0.2 ${((rank || 0) * 47 + 45) % 360}))`; // Company logo from companiesmarketcap.com (ticker-based). Falls back to a // colored initials avatar when the logo is missing or blocked. function SteelLogo({ row, size = 38 }: { row: SteelRow; size?: number }) { const [err, setErr] = useState(false); // companiesmarketcap keys logos by the FULL ticker incl. exchange suffix // (e.g. TATASTEEL.NS, 5401.T) — stripping it 404s every non-US company. const ticker = row.code.trim(); const dim = { width: size, height: size }; if (err || !ticker) { return (
{row.name.slice(0, 2)}
); } return ( setErr(true)} alt={`${row.name} logo`} style={dim} loading="lazy" className="shrink-0 rounded-full bg-white object-contain p-0.5 ring-1 ring-border" /> ); } function SteelStocksView() { const [rows, setRows] = useState([]); const [lastUpdate, setLastUpdate] = useState(""); useEffect(() => { let active = true; const load = async () => { try { const data: SteelRow[] = await fetch(`${API}/api/steel-stocks`, { cache: "no-store", }).then((r) => r.json()); if (!active || !Array.isArray(data) || data.length === 0) return; setRows(data); setLastUpdate(new Date().toLocaleTimeString("fa-IR")); } catch { /* keep last good data */ } }; load(); const id = setInterval(load, 5 * 60_000); return () => { active = false; clearInterval(id); }; }, []); const totalCap = useMemo(() => rows.reduce((s, r) => s + (r.market_cap || 0), 0), [rows]); const seriesInput = useMemo( () => rows.map((r) => ({ name: r.code, price: r.price || 0, pct: r.today_pct || 0 })), [rows], ); const series = useRollingSeries(seriesInput); const kpis: KpiItem[] = rows.slice(0, 3).map((r) => ({ label: r.name, value: r.price || 0, unit: "$", pct: r.today_pct ?? undefined, dir: r.dir, series: series[r.code], decimals: 2, })); const capStr = (v: number | null) => v == null ? "—" : faDigits("$" + (v >= 1000 ? (v / 1000).toFixed(2) + "T" : v.toFixed(2) + "B")); const pctText = (r: SteelRow) => r.today_pct != null ? faDigits( (r.dir === "down" ? "▼ " : r.dir === "up" ? "▲ " : "") + Math.abs(r.today_pct).toFixed(2) + "%", ) : "—"; const pctCls = (r: SteelRow) => r.dir === "down" ? "text-red-600" : r.dir === "up" ? "text-emerald-700" : "text-muted-foreground"; return (
0} title="قیمت سهام شرکت‌ها" subtitle={faDigits( `${rows.length} شرکت فولادی · مجموع ارزش بازار ${ totalCap >= 1000 ? (totalCap / 1000).toFixed(2) + "T$" : totalCap.toFixed(0) + "B$" }`, )} badge="هر ۵ دقیقه" lastUpdate={lastUpdate} /> {rows.length > 0 && } {rows.length === 0 ? ( ) : (
#
شرکت
کشور
ارزش بازار
قیمت
تغییر امروز
{rows.map((r) => (
{/* Mobile: clean stacked card */}
{r.name}
{r.code} · {STEEL_FLAGS[r.country] || "🏳️"} {r.country}
{r.rank != null ? faDigits("#" + r.rank) : "—"}
قیمت {r.price != null ? "$" + usd(r.price) : "—"}
ارزش بازار {capStr(r.market_cap)}
{pctText(r)}
{/* Desktop: aligned table row */}
{r.rank != null ? faDigits(String(r.rank)) : "—"}
{r.name}
{r.code}
{STEEL_FLAGS[r.country] || "🏳️"} {r.country}
{capStr(r.market_cap)}
{r.price != null ? "$" + usd(r.price) : "—"}
{pctText(r)}
))}
)}
); } function SteelBar({ pct, dir }: { pct: number | null; dir: "up" | "down" | "" }) { const mag = pct == null ? 0 : Math.min(Math.abs(pct) / 6, 1); const filled = Math.round(mag * 8); const color = dir === "down" ? "bg-red-500" : dir === "up" ? "bg-emerald-500" : "bg-muted-foreground/40"; return (
{Array.from({ length: 8 }).map((_, i) => ( ))}
); } type WorldRow = { country: string; gdp: number | null; gdp_growth: number | null; interest_rate: number | null; inflation_rate: number | null; jobless_rate: number | null; gov_budget: number | null; debt_gdp: number | null; current_account: number | null; population: number | null; fetched_at: string; }; const COUNTRY_FA: Record = { "United States": { fa: "آمریکا", flag: "🇺🇸" }, China: { fa: "چین", flag: "🇨🇳" }, "Euro Area": { fa: "منطقه یورو", flag: "🇪🇺" }, Germany: { fa: "آلمان", flag: "🇩🇪" }, Japan: { fa: "ژاپن", flag: "🇯🇵" }, India: { fa: "هند", flag: "🇮🇳" }, "United Kingdom": { fa: "انگلیس", flag: "🇬🇧" }, France: { fa: "فرانسه", flag: "🇫🇷" }, Italy: { fa: "ایتالیا", flag: "🇮🇹" }, Canada: { fa: "کانادا", flag: "🇨🇦" }, "South Korea": { fa: "کره جنوبی", flag: "🇰🇷" }, Russia: { fa: "روسیه", flag: "🇷🇺" }, Brazil: { fa: "برزیل", flag: "🇧🇷" }, Australia: { fa: "استرالیا", flag: "🇦🇺" }, Spain: { fa: "اسپانیا", flag: "🇪🇸" }, Mexico: { fa: "مکزیک", flag: "🇲🇽" }, Turkey: { fa: "ترکیه", flag: "🇹🇷" }, Indonesia: { fa: "اندونزی", flag: "🇮🇩" }, }; type Polarity = "tealHigh" | "orangeHigh" | "neutral"; const WE_COLS: { key: keyof WorldRow; label: string; pol: Polarity; suffix?: string; gdp?: boolean; }[] = [ { key: "gdp", label: "تولید ناخالص", pol: "neutral", gdp: true }, { key: "gdp_growth", label: "رشد سالانه", pol: "tealHigh", suffix: "٪" }, { key: "gov_budget", label: "تراز بودجه/GDP", pol: "tealHigh", suffix: "٪" }, { key: "debt_gdp", label: "بدهی/GDP", pol: "orangeHigh", suffix: "٪" }, { key: "interest_rate", label: "نرخ بهره", pol: "orangeHigh", suffix: "٪" }, { key: "inflation_rate", label: "تورم", pol: "orangeHigh", suffix: "٪" }, { key: "jobless_rate", label: "بیکاری", pol: "orangeHigh", suffix: "٪" }, { key: "current_account", label: "حساب جاری/GDP", pol: "tealHigh", suffix: "٪" }, ]; // Diverging teal <-> orange. t in [0,1]: 0 = teal end, 1 = orange end. function weColor(t: number): string { const c = Math.max(0, Math.min(1, t)); const dist = Math.abs(c - 0.5) * 2; const hue = c < 0.5 ? 195 : 45; const light = 0.92 - dist * 0.07; const chroma = 0.02 + dist * 0.13; return `oklch(${light.toFixed(3)} ${chroma.toFixed(3)} ${hue})`; } /* ─────────────────────────── Reports library view ─────────────────────────── */ type RepFile = { name: string; date: string | null; size: number; kind: string; source: string; category: string; folder: string; }; type RepCategory = { folder: string; category: string; count: number; latest: string | null; preview: RepFile[]; }; type RepCat = RepCategory & { source: string }; type RepSource = { source: string; categories: RepCategory[]; count: number; category_count: number; latest: string | null; }; type RepIndex = { sources: RepSource[]; latest: RepFile[]; total: number; dir: string; exists: boolean; }; const SOURCE_META: Record = { Fastmarkets: { fa: "Fastmarkets", desc: "فلزات، فولاد و مواد اولیه", grad: "from-amber-500 to-orange-600", soft: "bg-amber-500/10 text-amber-700", }, FerroAlloyNet: { fa: "FerroAlloyNet", desc: "فروآلیاژها و فلزات جزئی", grad: "from-sky-500 to-indigo-600", soft: "bg-indigo-500/10 text-indigo-700", }, Delphica: { fa: "Delphica", desc: "قیمت فولاد منطقهٔ CIS", grad: "from-emerald-500 to-teal-600", soft: "bg-emerald-500/10 text-emerald-700", }, Platts: { fa: "Platts · S&P", desc: "قیمت روزانهٔ فلزات", grad: "from-rose-500 to-pink-600", soft: "bg-rose-500/10 text-rose-700", }, Baiinfo: { fa: "Baiinfo", desc: "بازار فولاد چین", grad: "from-violet-500 to-purple-600", soft: "bg-violet-500/10 text-violet-700", }, }; const srcMeta = (s: string) => SOURCE_META[s] || { fa: s, desc: "", grad: "from-slate-500 to-slate-700", soft: "bg-slate-500/10 text-slate-700", }; const CAT_FA: Record = { "Base Metals": "فلزات پایه", "Ores and Alloys": "سنگ‌ها و آلیاژها", Steel: "فولاد", "Steel Price Reports": "گزارش قیمت فولاد", "Steel Raw Materials": "مواد اولیهٔ فولاد", "Steel Scrap": "قراضهٔ فولاد", Aluminum: "آلومینیوم", Chrome: "کروم", "Ferro Silicon": "فروسیلیس", "Iron Ore": "سنگ‌آهن", Magnesium: "منیزیم", Manganese: "منگنز", Molybdenum: "مولیبدن", Nickel: "نیکل", "Silicon Metal": "سیلیکون متال", Titanium: "تیتانیوم", Tungsten: "تنگستن", Vanadium: "وانادیوم", Zircon: "زیرکن", "CIS Steel Prices Weekly": "قیمت هفتگی فولاد CIS", "Steel Prices": "قیمت فولاد", China: "بازار چین", "Metals Daily": "فلزات روزانه", }; const catFa = (c: string) => CAT_FA[c] || c; const KIND_FA: Record = { daily: "روزانه", weekly: "هفتگی", monthly: "ماهانه", annual: "سالانه", report: "گزارش", }; const kindFa = (k: string) => KIND_FA[k] || "گزارش"; const fmtSize = (b: number) => faDigits( b >= 1048576 ? (b / 1048576).toFixed(1) + " MB" : Math.max(1, Math.round(b / 1024)) + " KB", ); const repFileUrl = (f: { folder: string; name: string }) => `${API}/api/reports/file?folder=${encodeURIComponent(f.folder)}&name=${encodeURIComponent(f.name)}`; // Reports tab is temporarily gated behind a "coming soon" screen. const REPORTS_COMING_SOON = true; function ReportsView() { const [idx, setIdx] = useState(null); const [loading, setLoading] = useState(true); const [sourceFilter, setSourceFilter] = useState("all"); const [query, setQuery] = useState(""); const [openCat, setOpenCat] = useState(null); useEffect(() => { if (REPORTS_COMING_SOON) return; // don't even fetch the report index let active = true; fetch(`${API}/api/reports`, { cache: "no-store" }) .then((r) => r.json()) .then((d: RepIndex) => active && setIdx(d)) .catch(() => {}) .finally(() => active && setLoading(false)); return () => { active = false; }; }, []); const allCats = useMemo( () => idx ? idx.sources.flatMap((s) => s.categories.map((c) => ({ ...c, source: s.source }))) : [], [idx], ); const filteredCats = useMemo(() => { let list = allCats; if (sourceFilter !== "all") list = list.filter((c) => c.source === sourceFilter); const q = query.trim().toLowerCase(); if (q) list = list.filter( (c) => c.category.toLowerCase().includes(q) || catFa(c.category).includes(q) || c.source.toLowerCase().includes(q), ); return [...list].sort((a, b) => (b.latest || "").localeCompare(a.latest || "")); }, [allCats, sourceFilter, query]); const latest = idx?.latest || []; const featured = latest[0]; const news = latest.slice(1, 7); if (REPORTS_COMING_SOON) { return (
{/* blurred placeholder so the real content is never shown */}
{Array.from({ length: 9 }).map((_, i) => (
))}
{/* popup */}

به‌زودی

بخش گزارش‌ها در حال آماده‌سازی است و به‌زودی در دسترس قرار می‌گیرد.

); } return (
{loading ? ( ) : !idx?.exists ? ( ) : ( <>
} label="کل گزارش‌ها" value={formatNumber(idx.total)} /> } label="منابع" value={formatNumber(idx.sources.length)} /> } label="دسته‌بندی‌ها" value={formatNumber(allCats.length)} /> } label="تازه‌ترین" value={featured?.date ? toJalali(featured.date) : "—"} />
{featured && (
} title="تازه‌ترین گزارش‌ها" hint="به‌روزترین گزارش‌های منتشرشده" />
{news.map((f, i) => ( ))}
)}
} title="منابع گزارش" hint="برای فیلتر، روی یک منبع بزنید" />
{idx.sources.map((s, i) => ( setSourceFilter((cur) => (cur === s.source ? "all" : s.source))} /> ))}
} title="دسته‌بندی‌ها" hint={`${filteredCats.length} دسته`} />
setQuery(e.target.value)} placeholder="جستجوی دسته یا منبع…" className="h-10 w-full sm:w-64 rounded-lg border border-border bg-card pr-9 pl-3 text-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/15" />
setSourceFilter("all")}> همه {idx.sources.map((s) => ( setSourceFilter(s.source)} > {srcMeta(s.source).fa} ))}
{filteredCats.map((c, i) => ( setOpenCat(c)} /> ))}
{filteredCats.length === 0 && }
)} {openCat && setOpenCat(null)} />}
); } function RepStat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) { return (
{icon}
{label}
{value}
); } function RepSectionTitle({ icon, title, hint }: { icon: ReactNode; title: string; hint?: string }) { return (
{icon}

{title}

{hint && · {hint}}
); } function RepFeatured({ file }: { file: RepFile }) { const m = srcMeta(file.source); return (
تازه‌ترین {file.date ? toJalali(file.date) : ""}
{m.fa} · {catFa(file.category)}

{catFa(file.category)} — {kindFa(file.kind)}

مشاهدهٔ گزارش
); } function RepNewsCard({ file, index }: { file: RepFile; index: number }) { const m = srcMeta(file.source); return (
{m.fa} {file.date ? toJalali(file.date) : "—"}
{catFa(file.category)} — {kindFa(file.kind)}
{fmtSize(file.size)} باز کردن
); } function RepSourceCard({ src, index, active, onClick, }: { src: RepSource; index: number; active: boolean; onClick: () => void; }) { const m = srcMeta(src.source); return ( ); } function RepChip({ active, onClick, children, }: { active: boolean; onClick: () => void; children: ReactNode; }) { return ( ); } function RepCategoryCard({ cat, index, onOpen, }: { cat: RepCat; index: number; onOpen: () => void; }) { const m = srcMeta(cat.source); return ( ); } function RepCategoryModal({ cat, onClose }: { cat: RepCat; onClose: () => void }) { const [files, setFiles] = useState(null); const [q, setQ] = useState(""); const m = srcMeta(cat.source); useEffect(() => { let active = true; fetch(`${API}/api/reports/files?folder=${encodeURIComponent(cat.folder)}`, { cache: "no-store", }) .then((r) => r.json()) .then((d: RepFile[]) => active && setFiles(Array.isArray(d) ? d : [])) .catch(() => active && setFiles([])); return () => { active = false; }; }, [cat.folder]); const shown = useMemo(() => { if (!files) return []; const s = q.trim().toLowerCase(); return s ? files.filter((f) => f.name.toLowerCase().includes(s) || (f.date || "").includes(s)) : files; }, [files, q]); return ( e.stopPropagation()} >
{m.fa}

{catFa(cat.category)}

{formatNumber(cat.count)} گزارش
setQ(e.target.value)} placeholder="جستجو در گزارش‌ها…" className="h-10 w-full rounded-lg border border-border bg-background pr-9 pl-3 text-sm outline-none focus:border-primary/60" />
{!files ? (
) : shown.length === 0 ? ( ) : (
{shown.map((f) => (
{f.name}
{f.date ? toJalali(f.date) : "—"} · {fmtSize(f.size)}
باز کردن
))}
)}
); } /* ─────────────────────────── Crypto view ─────────────────────────── */ type CoinRow = { id: string; symbol: string; name: string; image: string; rank: number | null; price: number | null; pct_1h: number | null; pct_24h: number | null; pct_7d: number | null; market_cap: number | null; volume_24h: number | null; high_24h: number | null; low_24h: number | null; circ_supply: number | null; ath: number | null; sparkline: number[]; dir: "up" | "down" | ""; }; type CryptoGlobal = { market_cap_usd: number | null; volume_usd: number | null; btc_dominance: number | null; eth_dominance: number | null; market_cap_change_24h: number | null; active: number | null; }; type TrendingCoin = { id: string; symbol: string; name: string; rank: number | null; thumb: string | null; price: number | null; pct_24h: number | null; }; type CryptoPayload = { coins: CoinRow[]; global: CryptoGlobal; trending: TrendingCoin[]; fetched_at: string | null; }; const cryptoPrice = (p: number | null) => { if (p == null || !Number.isFinite(p)) return "—"; if (p >= 1) return faDigits( "$" + p.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }), ); if (p >= 0.01) return faDigits( "$" + p.toLocaleString("en-US", { minimumFractionDigits: 4, maximumFractionDigits: 4 }), ); return faDigits("$" + p.toLocaleString("en-US", { maximumFractionDigits: 8 })); }; const usdAbbr = (n: number | null) => { if (n == null || !Number.isFinite(n)) return "—"; if (n >= 1e12) return faDigits("$" + (n / 1e12).toFixed(2) + "T"); if (n >= 1e9) return faDigits("$" + (n / 1e9).toFixed(2) + "B"); if (n >= 1e6) return faDigits("$" + (n / 1e6).toFixed(2) + "M"); return faDigits("$" + n.toLocaleString("en-US", { maximumFractionDigits: 0 })); }; const pctClass = (p: number | null) => p == null ? "text-muted-foreground" : p >= 0 ? "text-emerald-700" : "text-red-600"; const pctStr = (p: number | null) => p == null ? "—" : faDigits((p >= 0 ? "▲ " : "▼ ") + Math.abs(p).toFixed(2) + "%"); function CryptoView() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [query, setQuery] = useState(""); useEffect(() => { let active = true; const load = () => fetch(`${API}/api/crypto`, { cache: "no-store" }) .then((r) => r.json()) .then((d: CryptoPayload) => active && d && setData(d)) .catch(() => {}) .finally(() => active && setLoading(false)); load(); const id = setInterval(load, 60_000); return () => { active = false; clearInterval(id); }; }, []); const coins = data?.coins || []; const g = data?.global; const trending = data?.trending || []; const featured = coins.slice(0, 3); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); return q ? coins.filter((c) => c.name.toLowerCase().includes(q) || c.symbol.toLowerCase().includes(q)) : coins; }, [coins, query]); return (
0} title="کریپتو" subtitle="بازار ارزهای دیجیتال — زنده از CoinGecko" badge="هر ۶۰ ثانیه" lastUpdate={data?.fetched_at ? new Date(data.fetched_at).toLocaleTimeString("fa-IR") : ""} /> {loading && !data ? ( ) : coins.length === 0 ? ( ) : ( <> {g && (
)}
{featured.map((c, i) => ( ))}
{trending.length > 0 && (
} title="ترند امروز" hint="پرجستجوترین کوین‌ها" />
{trending.map((t) => ( ))}
)}
} title="بازار" hint={`${filtered.length} کوین`} />
setQuery(e.target.value)} placeholder="جستجوی کوین…" className="h-10 w-full sm:w-64 rounded-lg border border-border bg-card pr-9 pl-3 text-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/15" />
{filtered.map((c, i) => ( ))}
{filtered.length === 0 && }
)}
); } function CryptoStat({ label, value, change, }: { label: string; value: string; change?: number | null; }) { return (
{label}
{value} {change != null && ( {change >= 0 ? "▲" : "▼"} {faDigits(Math.abs(change).toFixed(2) + "%")} )}
); } function CryptoKpiCard({ coin, delay }: { coin: CoinRow; delay: number }) { const up = (coin.pct_24h ?? 0) >= 0; return (
{coin.name}
{coin.symbol}
{pctStr(coin.pct_24h)}
{cryptoPrice(coin.price)}
= 0 ? "up" : "down"} width={108} height={38} strokeWidth={1.8} dot />
مارکت‌کپ {usdAbbr(coin.market_cap)}
); } function CoinCard({ coin, index }: { coin: CoinRow; index: number }) { const sdir: "up" | "down" = (coin.pct_7d ?? 0) >= 0 ? "up" : "down"; return (
{coin.rank != null ? faDigits(String(coin.rank)) : "—"}
{coin.name}
{coin.symbol}
cryptoPrice(n)} />
۱h{" "} {coin.pct_1h != null ? faDigits((coin.pct_1h >= 0 ? "+" : "") + coin.pct_1h.toFixed(1) + "%") : "—"}
{usdAbbr(coin.market_cap)}
{coin.sparkline && coin.sparkline.length > 1 ? (
) : (
)}
); } function TrendingChip({ coin }: { coin: TrendingCoin }) { const up = (coin.pct_24h ?? 0) >= 0; return (
{coin.thumb && ( )} {coin.symbol} {coin.pct_24h != null && ( {up ? "▲" : "▼"} {faDigits(Math.abs(coin.pct_24h).toFixed(1) + "%")} )}
); } function WorldEconomyView() { const [rows, setRows] = useState([]); const [lastUpdate, setLastUpdate] = useState(""); useEffect(() => { let active = true; const load = async () => { try { const data: WorldRow[] = await fetch(`${API}/api/world-economy`, { cache: "no-store", }).then((r) => r.json()); if (!active || !Array.isArray(data) || data.length === 0) return; setRows(data); setLastUpdate(new Date().toLocaleTimeString("fa-IR")); } catch { /* keep last good data */ } }; load(); const id = setInterval(load, 5 * 60_000); // macro data changes slowly return () => { active = false; clearInterval(id); }; }, []); const ranges = useMemo(() => { const m: Record = {}; for (const col of WE_COLS) { if (col.pol === "neutral") continue; const vals = rows .map((r) => r[col.key] as number | null) .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); if (vals.length) m[col.key] = { min: Math.min(...vals), max: Math.max(...vals) }; } return m; }, [rows]); const cellStyle = (col: (typeof WE_COLS)[number], val: number | null) => { if (col.pol === "neutral" || val == null) return undefined; const rg = ranges[col.key]; if (!rg || rg.max === rg.min) return undefined; const norm = (val - rg.min) / (rg.max - rg.min); const t = col.pol === "orangeHigh" ? norm : 1 - norm; return { background: weColor(t) }; }; const fmtCell = (col: (typeof WE_COLS)[number], val: number | null) => { if (val == null) return "—"; if (col.gdp) return (val / 1000).toLocaleString("fa-IR", { maximumFractionDigits: 2 }) + " T$"; return formatNumber(val) + (col.suffix || ""); }; return (
0} title="اقتصاد جهانی" subtitle="شاخص‌های کلانِ اقتصادهای بزرگ — رنگ‌بندی حرارتی برای هر ستون" badge="Trading Economics" lastUpdate={lastUpdate} /> {rows.length === 0 ? ( ) : (
{WE_COLS.map((c) => ( ))} {rows.map((r) => ( {WE_COLS.map((c) => { const v = r[c.key] as number | null; return ( ); })} ))}
کشور {c.label}
{COUNTRY_FA[r.country]?.flag || "🏳️"} {COUNTRY_FA[r.country]?.fa || r.country} {fmtCell(c, v)}
)}
); } /* ─────────────────────────── Metals & steel view ─────────────────────────── */ type Range = 30 | 90 | 180 | 365 | "all"; const RANGES: { label: string; value: Range }[] = [ { label: "۳۰ روز", value: 30 }, { label: "۹۰ روز", value: 90 }, { label: "۱۸۰ روز", value: 180 }, { label: "۱ سال", value: 365 }, { label: "کل", value: "all" }, ]; const COLORS = [ "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", ]; type Tree = Record>; type SearchResult = { title: string; group: string; category: string }; /* Curated families — backend category is unreliable, so classify by product title. Order = priority. */ const METAL_FAMILIES: { key: string; label: string; kw: string[] }[] = [ { key: "scrap", label: "قراضه و بازیافت", kw: ["scrap"] }, { key: "rare", label: "عناصر کمیاب خاکی", kw: [ "neodymium", "cerium", "lanthanum", "mischmetal", "gadolinium", "yttrium", "samarium", "praseodymium", "terbium", "scandium", "ytterbium", "lutetium", "dysprosium", "europium", "holmium", "erbium", "didymium", "reo", "ce/re", "yeu", ], }, { key: "ferro", label: "فروآلیاژها", kw: [ "ferro-", "ferro ", "silico-manganese", "silico-chrome", "silico-chrom", "calcium-silicon", "calcium silicon", ], }, { key: "steelprod", label: "محصولات فولادی", kw: [ "rebar", "wire rod", "coil", "hot rolled", "cold rolled", "plate", "section", "angle steel", "channel steel", "beam", "strip", "pipe", "billet", "stainless", "sheet", "hgi", "galvan", "electrical steel", "cold heading", "seamless", "welded", ], }, { key: "raw", label: "مواد اولیه فولاد", kw: [ "iron ore", "coking coal", "thermal coal", "coal", "coke", "pet coke", "pig iron", "cast iron", "manganese ore", "chrome ore", "sinter", "sponge iron", ], }, { key: "refractory", label: "دیرگداز و کربن", kw: [ "magnesia", "fused alumina", "calcined alumina", "bauxite", "silicon carbide", "graphite", "carbon", "coal tar pitch", "electrode", ], }, { key: "base", label: "فلزات پایه", kw: [ "copper", "aluminum", "aluminium", "zinc ", "lead ", "lead conc", "lead ingot", "nickel", "tin ", ], }, ]; const metalFamily = (title: string): string => { const t = " " + title.toLowerCase() + " "; for (const f of METAL_FAMILIES) if (f.kw.some((k) => t.includes(k))) return f.key; return "minor"; }; const FAMILY_LABEL: Record = { ...Object.fromEntries(METAL_FAMILIES.map((f) => [f.key, f.label])), minor: "فلزات جزئی و ویژه", }; const FAMILY_ORDER = [...METAL_FAMILIES.map((f) => f.key), "minor"]; function MetalsView({ jumpTo, onConsumeJump, }: { jumpTo: string | null; onConsumeJump: () => void; }) { const [tree, setTree] = useState({}); const [selected, setSelected] = useState(null); const [activeHistory, setActiveHistory] = useState([]); const [compare, setCompare] = useState([]); const [compareHistories, setCompareHistories] = useState>({}); const [range, setRange] = useState("all"); const [search, setSearch] = useState(""); const [searchResults, setSearchResults] = useState([]); const [openGroups, setOpenGroups] = useState>({}); const [openCats, setOpenCats] = useState>({}); const [mode, setMode] = useState<"single" | "compare">("single"); const [loadingTree, setLoadingTree] = useState(true); const [loadingHistory, setLoadingHistory] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [watchlist, setWatchlist] = useState([]); const [watchOpen, setWatchOpen] = useState(true); const firstSave = useRef(true); useEffect(() => { try { const saved = JSON.parse(localStorage.getItem("am_watchlist") || "[]"); if (Array.isArray(saved)) setWatchlist(saved); } catch { /* ignore corrupt storage */ } }, []); useEffect(() => { if (firstSave.current) { firstSave.current = false; return; } localStorage.setItem("am_watchlist", JSON.stringify(watchlist)); }, [watchlist]); const openTo = (title: string, group: string, category: string) => { setSelected(title); setMode("single"); setOpenGroups((g) => ({ ...g, [group]: true })); setOpenCats((c) => ({ ...c, [`${group}::${category}`]: true })); }; useEffect(() => { fetch(`${API}/api/tree`) .then((r) => r.json()) .then((data: Tree) => { setTree(data); const g = Object.keys(data)[0]; if (g) { const c = Object.keys(data[g])[0]; const t = c ? data[g][c][0] : null; if (t) { setSelected(t); setOpenGroups({ [g]: true }); setOpenCats({ [`${g}::${c}`]: true }); } } }) .finally(() => setLoadingTree(false)); }, []); // honor an external jump request (e.g. from search before this view mounted) useEffect(() => { if (jumpTo) { setSelected(jumpTo); onConsumeJump(); } }, [jumpTo, onConsumeJump]); useEffect(() => { if (!selected) return; setLoadingHistory(true); const days = range === "all" ? 0 : range; fetch(`${API}/api/history?title=${encodeURIComponent(selected)}&days=${days}`) .then((r) => r.json()) .then(setActiveHistory) .finally(() => setLoadingHistory(false)); }, [selected, range]); useEffect(() => { if (mode !== "compare" || compare.length === 0) return; const days = range === "all" ? 0 : range; Promise.all( compare.map((title) => fetch(`${API}/api/history?title=${encodeURIComponent(title)}&days=${days}`) .then((r) => r.json()) .then((data) => ({ title, data })), ), ).then((results) => { const map: Record = {}; for (const { title, data } of results) map[title] = data; setCompareHistories(map); }); }, [compare, range, mode]); useEffect(() => { if (!search.trim()) { setSearchResults([]); return; } const t = setTimeout(() => { fetch(`${API}/api/search?q=${encodeURIComponent(search.trim())}`) .then((r) => r.json()) .then(setSearchResults); }, 300); return () => clearTimeout(t); }, [search]); const compareData = useMemo(() => { if (mode !== "compare" || compare.length === 0) return []; const dateSet = new Set(); const seriesData = compare.map((title) => { const rows = compareHistories[title] || []; rows.forEach((r) => dateSet.add(r.date)); return { title, map: new Map(rows.map((r) => [r.date, r.mid])) }; }); return Array.from(dateSet) .sort() .map((d) => { const row: Record = { date: d }; for (const s of seriesData) { const v = s.map.get(d); if (v !== undefined) row[s.title] = v; } return row; }); }, [mode, compare, compareHistories]); const stats = useMemo(() => { if (!activeHistory.length) return null; const last = activeHistory[activeHistory.length - 1]; const prev = activeHistory[activeHistory.length - 2]; const pct = prev ? ((last.mid - prev.mid) / prev.mid) * 100 : 0; return { last, pct, hi: Math.max(...activeHistory.map((r) => r.high)), lo: Math.min(...activeHistory.map((r) => r.low)), avg: activeHistory.reduce((s, r) => s + r.mid, 0) / activeHistory.length, }; }, [activeHistory]); const selectedMeta = useMemo(() => { if (!selected) return null; for (const [group, cats] of Object.entries(tree)) for (const [cat, titles] of Object.entries(cats)) if (titles.includes(selected)) return { group, category: cat }; return null; }, [selected, tree]); const toggleCompare = (title: string) => setCompare((c) => c.includes(title) ? c.filter((t) => t !== title) : c.length < 5 ? [...c, title] : c, ); const toggleWatch = (title: string) => setWatchlist((w) => (w.includes(title) ? w.filter((t) => t !== title) : [...w, title])); const pick = (title: string) => { if (mode === "compare") toggleCompare(title); else setSelected(title); setSidebarOpen(false); }; type LatestRow = { group: string; category: string; title: string; date: string; low: number; mid: number; high: number; prev_mid: number | null; pct: number; }; const [latest, setLatest] = useState([]); const [catGroup, setCatGroup] = useState("all"); const [catWatch, setCatWatch] = useState(false); useEffect(() => { fetch(`${API}/api/latest`) .then((r) => r.json()) .then((d) => Array.isArray(d) && setLatest(d)) .catch(() => {}); }, []); const catGroups = useMemo(() => { const seen: string[] = []; for (const r of latest) if (!seen.includes(r.group)) seen.push(r.group); return seen; }, [latest]); const catalog = useMemo(() => { let list = latest; if (catWatch) list = list.filter((r) => watchlist.includes(r.title)); else if (catGroup !== "all") list = list.filter((r) => r.group === catGroup); const q = search.trim().toLowerCase(); if (q) list = list.filter( (r) => r.title.toLowerCase().includes(q) || categoryLabel(r.category).includes(q), ); return list; }, [latest, catGroup, catWatch, search, watchlist]); const catalogByGroup = useMemo(() => { const m: Record = {}; for (const r of catalog) (m[r.group] ||= []).push(r); return m; }, [catalog]); return (
{mode === "compare" ? "حالت مقایسه" : "محصول انتخاب‌شده"}

{mode === "compare" ? `${compare.length} محصول انتخاب شده` : selected || "محصولی انتخاب نشده"}

{mode === "single" && selectedMeta && (
{groupLabel(selectedMeta.group)} › {categoryLabel(selectedMeta.category)}
)}
{RANGES.map((r) => ( ))}
{/* Catalog — categorized by family, above the chart */}
کاتالوگ محصولات · {catalog.length}
setSearch(e.target.value)} placeholder="جستجوی محصول…" className="h-10 w-full sm:w-72 rounded-lg border border-border bg-card pr-9 pl-3 text-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/15" />
{ setCatGroup("all"); setCatWatch(false); }} > همه {watchlist.length > 0 && ( setCatWatch((w) => !w)}> ★ واچ‌لیست )} {catGroups.map((k) => ( { setCatGroup(k); setCatWatch(false); }} > {groupLabel(k)} ))}
{loadingTree && latest.length === 0 ? (
در حال بارگذاری…
) : catalog.length === 0 ? ( ) : catGroup === "all" && !catWatch && !search.trim() ? (
{catGroups.map((k) => (
{groupLabel(k)} {catalogByGroup[k]?.length ?? 0}
{(catalogByGroup[k] || []).map((row, i) => ( pick(row.title)} onStar={() => toggleWatch(row.title)} /> ))}
))}
) : (
{catalog.map((row, i) => ( pick(row.title)} onStar={() => toggleWatch(row.title)} /> ))}
)}
{mode === "single" && selected && (loadingHistory ? ( ) : stats ? ( <>
} /> = 0 ? "up" : "down"} icon={ stats.pct >= 0 ? ( ) : ( ) } />
روند قیمت · میانگین با باند کف/سقف
({ ...r, bandWidth: r.high - r.low }))} margin={{ top: 10, right: 10, left: 10, bottom: 0 }} > toJalali(v)} /> formatNumber(v as number)} width={80} orientation="right" /> } /> { if (props.index !== activeHistory.length - 1) return ; const { cx, cy } = props; return ( ); }} activeDot={{ r: 4 }} />
) : ( ))} {mode === "compare" && (compare.length === 0 ? ( ) : ( <>
{compare.map((t, i) => (
{t}
))}
مقایسه · میانگین قیمت
toJalali(v)} /> formatNumber(v as number)} width={80} orientation="right" /> } /> {compare.map((t, i) => ( ))}
))}
); } /* ─────────────────────────── Shared UI ─────────────────────────── */ function ViewHeader({ live, title, subtitle, badge, lastUpdate, }: { live: boolean; title: string; subtitle: string; badge: string; lastUpdate: string; }) { return (

{title}

{badge}

{subtitle}

{lastUpdate && ( آخرین بروزرسانی: {lastUpdate} )}
); } function Section({ title, children }: { title: string; children: ReactNode }) { return (

{title}

{children}
); } function SkeletonGrid({ count }: { count: number }) { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } function CatChip({ active, onClick, children, }: { active: boolean; onClick: () => void; children: ReactNode; }) { return ( ); } function MetalCatalogCard({ row, active, starred, onPick, onStar, index, }: { row: { title: string; category: string; mid: number; pct: number; date: string }; active: boolean; starred: boolean; onPick: () => void; onStar: () => void; index?: number; }) { const dir: "up" | "down" | "" = row.pct > 0 ? "up" : row.pct < 0 ? "down" : ""; return (
{row.title}
{categoryLabel(row.category)}
{formatNumber(row.mid)}
{row.date && (
{toJalali(row.date)}
)}
); } function SidebarItem({ title, active, starred, onPick, onStar, }: { title: string; active: boolean; starred: boolean; onPick: () => void; onStar: () => void; }) { return (
); } function StatCard({ label, value, sub, tone, icon, }: { label: string; value: string; sub?: string; tone?: "up" | "down"; icon?: ReactNode; }) { return (
{label} {icon}
{value}
{sub &&
{sub}
}
); } function ChartSkeleton() { return (
{Array.from({ length: 4 }).map((_, i) => (
))}
); } function EmptyState({ text }: { text: string }) { return (
{text}
); } function ChartTooltip({ active, payload, label }: any) { if (!active || !payload?.length) return null; return (
{toJalali(label)}
{payload.map((p: any) => (
{p.dataKey}: {formatNumber(p.value)}
))}
); }