feat(lme): add LME navbar tab and Westmetall live market data feed
This commit is contained in:
parent
757ed39eef
commit
b6497a4b13
|
|
@ -44,6 +44,7 @@ import currency_stream
|
|||
import world_economy
|
||||
import steel_stocks
|
||||
import reports
|
||||
import scrape_lme
|
||||
import crypto_market
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -247,6 +248,14 @@ def get_steel_stocks(_: object = Depends(require_session)):
|
|||
def get_crypto(_: object = Depends(require_session)):
|
||||
return crypto_market.get_latest()
|
||||
|
||||
|
||||
@app.get("/api/lme")
|
||||
def get_lme(_: object = Depends(require_session)):
|
||||
try:
|
||||
return scrape_lme.get_lme_data()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"Failed to fetch LME data: {e}")
|
||||
|
||||
@app.get("/api/reports")
|
||||
def get_reports(_: object = Depends(require_session)):
|
||||
return reports.get_index()
|
||||
|
|
@ -420,4 +429,8 @@ def v1_crypto(): return get_crypto(None)
|
|||
@data_api.get("/world-economy")
|
||||
def v1_world_economy(): return get_world_economy(None)
|
||||
|
||||
|
||||
@data_api.get("/lme")
|
||||
def v1_lme(): return scrape_lme.get_lme_data()
|
||||
|
||||
app.include_router(data_api)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import urllib.request
|
||||
import re
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import time
|
||||
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'en-US,en;q=0.5'
|
||||
}
|
||||
|
||||
_cache = {
|
||||
"data": None,
|
||||
"last_fetched": 0
|
||||
}
|
||||
_lock = threading.Lock()
|
||||
|
||||
def clean_num(val: str) -> float:
|
||||
if not val:
|
||||
return 0.0
|
||||
val = val.replace(',', '').replace('+', '').replace('$', '').strip()
|
||||
try:
|
||||
return float(val)
|
||||
except:
|
||||
return 0.0
|
||||
|
||||
FA_NAMES = {
|
||||
"Copper": "مس (Copper)",
|
||||
"Tin": "قلع (Tin)",
|
||||
"Lead": "سرب (Lead)",
|
||||
"Zinc": "روی (Zinc)",
|
||||
"Aluminium": "آلومینیوم (Aluminium)",
|
||||
"Nickel": "نیکل (Nickel)",
|
||||
"Aluminium Alloy": "آلیاژ آلومینیوم",
|
||||
"NASAAC": "نازاک (NASAAC)"
|
||||
}
|
||||
|
||||
def fetch_raw():
|
||||
url = 'https://www.westmetall.com/en/markdaten.php'
|
||||
req = urllib.request.Request(url, headers=HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=12) as resp:
|
||||
html = resp.read().decode('utf-8', errors='ignore')
|
||||
|
||||
tables = re.findall(r'<table[^>]*>([\s\S]*?)</table>', html)
|
||||
if len(tables) < 2:
|
||||
return None
|
||||
|
||||
# Table 0: Official LME Prices
|
||||
t0_rows = re.findall(r'<tr[^>]*>([\s\S]*?)</tr>', tables[0])
|
||||
date_str = ""
|
||||
prices = []
|
||||
if t0_rows:
|
||||
header_cells = re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', t0_rows[0])
|
||||
if len(header_cells) > 1:
|
||||
date_str = re.sub(r'<.*?>', '', header_cells[1]).strip()
|
||||
|
||||
for r in t0_rows[2:]:
|
||||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', r)]
|
||||
if len(cells) >= 3 and cells[0]:
|
||||
prices.append({
|
||||
"metal": cells[0],
|
||||
"name_fa": FA_NAMES.get(cells[0], cells[0]),
|
||||
"cash": clean_num(cells[1]),
|
||||
"cash_str": cells[1],
|
||||
"three_months": clean_num(cells[2]),
|
||||
"three_months_str": cells[2],
|
||||
"unit": "USD/mt"
|
||||
})
|
||||
|
||||
# Table 1: LME Stocks
|
||||
t1_rows = re.findall(r'<tr[^>]*>([\s\S]*?)</tr>', tables[1])
|
||||
stocks = []
|
||||
if len(t1_rows) > 2:
|
||||
for r in t1_rows[2:]:
|
||||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', r)]
|
||||
if len(cells) >= 3 and cells[0]:
|
||||
stocks.append({
|
||||
"metal": cells[0],
|
||||
"name_fa": FA_NAMES.get(cells[0], cells[0]),
|
||||
"stocks": clean_num(cells[1]),
|
||||
"stocks_str": cells[1],
|
||||
"change": clean_num(cells[2]),
|
||||
"change_str": cells[2],
|
||||
"unit": "mt"
|
||||
})
|
||||
|
||||
# Combine Prices + Stocks by metal
|
||||
combined_metals = []
|
||||
stocks_map = {s["metal"]: s for s in stocks}
|
||||
for p in prices:
|
||||
m_name = p["metal"]
|
||||
s_data = stocks_map.get(m_name, {})
|
||||
spread = round(p["three_months"] - p["cash"], 2)
|
||||
combined_metals.append({
|
||||
"metal": m_name,
|
||||
"name_fa": p["name_fa"],
|
||||
"cash_settlement": p["cash"],
|
||||
"cash_str": p["cash_str"],
|
||||
"three_months": p["three_months"],
|
||||
"three_months_str": p["three_months_str"],
|
||||
"spread": spread,
|
||||
"spread_str": f"{spread:+,.2f}",
|
||||
"market_condition": "Contango" if spread > 0 else ("Backwardation" if spread < 0 else "Flat"),
|
||||
"stocks": s_data.get("stocks", 0),
|
||||
"stocks_str": s_data.get("stocks_str", "-"),
|
||||
"stocks_change": s_data.get("change", 0),
|
||||
"stocks_change_str": s_data.get("change_str", "-"),
|
||||
"unit": "USD/mt"
|
||||
})
|
||||
|
||||
# Table 2: FX Rates
|
||||
fx = []
|
||||
if len(tables) > 2:
|
||||
for r in re.findall(r'<tr[^>]*>([\s\S]*?)</tr>', tables[2])[1:]:
|
||||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', r)]
|
||||
if len(cells) >= 2 and cells[0]:
|
||||
fx.append({
|
||||
"pair": cells[0],
|
||||
"rate": clean_num(cells[1]),
|
||||
"rate_str": cells[1]
|
||||
})
|
||||
|
||||
# Table 4: Precious Metals
|
||||
precious = []
|
||||
if len(tables) > 4:
|
||||
for r in re.findall(r'<tr[^>]*>([\s\S]*?)</tr>', tables[4])[1:]:
|
||||
cells = [re.sub(r'<.*?>', '', c).replace(' ', ' ').strip() for c in re.findall(r'<t[dh][^>]*>([\s\S]*?)</t[dh]>', r)]
|
||||
if len(cells) >= 2 and cells[0]:
|
||||
precious.append({
|
||||
"name": cells[0],
|
||||
"price_str": cells[1]
|
||||
})
|
||||
|
||||
return {
|
||||
"source": "Westmetall GmbH / London Metal Exchange (LME)",
|
||||
"source_url": "https://www.westmetall.com/en/markdaten.php",
|
||||
"date": date_str or datetime.utcnow().strftime("%d. %B %Y"),
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
"metals": combined_metals,
|
||||
"stocks": stocks,
|
||||
"exchange_rates": fx,
|
||||
"precious_metals": precious
|
||||
}
|
||||
|
||||
def get_lme_data(ttl_seconds: int = 180):
|
||||
now = time.time()
|
||||
with _lock:
|
||||
if _cache["data"] and (now - _cache["last_fetched"]) < ttl_seconds:
|
||||
return _cache["data"]
|
||||
try:
|
||||
fresh = fetch_raw()
|
||||
if fresh:
|
||||
_cache["data"] = fresh
|
||||
_cache["last_fetched"] = now
|
||||
return fresh
|
||||
except Exception as e:
|
||||
if _cache["data"]:
|
||||
return _cache["data"]
|
||||
raise e
|
||||
return _cache["data"]
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Search,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Warehouse,
|
||||
Coins,
|
||||
DollarSign,
|
||||
Layers,
|
||||
ArrowUpDown,
|
||||
ShieldCheck,
|
||||
Globe
|
||||
} from "lucide-react";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
const API = API_BASE;
|
||||
|
||||
export interface LmeMetal {
|
||||
metal: string;
|
||||
name_fa: string;
|
||||
cash_settlement: number;
|
||||
cash_str: string;
|
||||
three_months: number;
|
||||
three_months_str: string;
|
||||
spread: number;
|
||||
spread_str: string;
|
||||
market_condition: "Contango" | "Backwardation" | "Flat";
|
||||
stocks: number;
|
||||
stocks_str: string;
|
||||
stocks_change: number;
|
||||
stocks_change_str: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface LmeFx {
|
||||
pair: string;
|
||||
rate: number;
|
||||
rate_str: string;
|
||||
}
|
||||
|
||||
export interface LmePrecious {
|
||||
name: string;
|
||||
price_str: string;
|
||||
}
|
||||
|
||||
export interface LmeData {
|
||||
source: string;
|
||||
source_url: string;
|
||||
date: string;
|
||||
updated_at: string;
|
||||
metals: LmeMetal[];
|
||||
stocks: any[];
|
||||
exchange_rates: LmeFx[];
|
||||
precious_metals: LmePrecious[];
|
||||
}
|
||||
|
||||
const METAL_COLORS: Record<string, { border: string; bg: string; text: string; badge: string }> = {
|
||||
Copper: { border: "border-amber-500/30", bg: "from-amber-500/10 to-orange-500/5", text: "text-amber-500", badge: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
|
||||
Aluminium: { border: "border-sky-500/30", bg: "from-sky-500/10 to-blue-500/5", text: "text-sky-500", badge: "bg-sky-500/10 text-sky-600 dark:text-sky-400" },
|
||||
Zinc: { border: "border-emerald-500/30", bg: "from-emerald-500/10 to-teal-500/5", text: "text-emerald-500", badge: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400" },
|
||||
Nickel: { border: "border-purple-500/30", bg: "from-purple-500/10 to-indigo-500/5", text: "text-purple-500", badge: "bg-purple-500/10 text-purple-600 dark:text-purple-400" },
|
||||
Lead: { border: "border-slate-500/30", bg: "from-slate-500/10 to-zinc-500/5", text: "text-slate-400", badge: "bg-slate-500/10 text-slate-600 dark:text-slate-300" },
|
||||
Tin: { border: "border-cyan-500/30", bg: "from-cyan-500/10 to-teal-500/5", text: "text-cyan-500", badge: "bg-cyan-500/10 text-cyan-600 dark:text-cyan-400" },
|
||||
};
|
||||
|
||||
export function LmeView() {
|
||||
const [data, setData] = useState<LmeData | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [lastRefreshed, setLastRefreshed] = useState<string>("");
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lme`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json: LmeData = await res.json();
|
||||
setData(json);
|
||||
setError(null);
|
||||
setLastRefreshed(new Date().toLocaleTimeString("fa-IR"));
|
||||
} catch (err: any) {
|
||||
setError(err.message || "خطا در دریافت دادههای LME");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const filteredMetals = useMemo(() => {
|
||||
if (!data?.metals) return [];
|
||||
if (!search.trim()) return data.metals;
|
||||
const q = search.toLowerCase().trim();
|
||||
return data.metals.filter(
|
||||
(m) =>
|
||||
m.metal.toLowerCase().includes(q) ||
|
||||
m.name_fa.toLowerCase().includes(q)
|
||||
);
|
||||
}, [data, search]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6" dir="rtl">
|
||||
{/* Header Banner */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border/60 bg-gradient-to-br from-card via-card/90 to-card/60 p-5 sm:p-6 backdrop-blur-xl shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-2.5 w-2.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 tracking-wide uppercase">
|
||||
بورس فلزات لندن (London Metal Exchange)
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-foreground flex items-center gap-2.5">
|
||||
<span>قیمتهای رسمی LME & موجودی انبارها</span>
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
بروزرسانی روزانه قیمتهای تسویه نقدی (Cash Settlement)، قراردادهای ۳ ماهه و انبارداری از مرجع رسمی Westmetall
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{data?.date && (
|
||||
<div className="rounded-xl border border-border/80 bg-background/60 px-3 py-1.5 text-xs text-muted-foreground shadow-sm">
|
||||
تاریخ بازار: <span className="font-semibold text-foreground">{data.date}</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border/80 bg-background/60 px-3 py-1.5 text-xs font-medium text-foreground transition hover:bg-accent disabled:opacity-50 shadow-sm"
|
||||
title="بروزرسانی دادهها"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin text-primary" : ""}`} />
|
||||
<span>{loading ? "در حال دریافت..." : "بروزرسانی"}</span>
|
||||
</button>
|
||||
<a
|
||||
href="https://www.westmetall.com/en/markdaten.php"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90"
|
||||
>
|
||||
<span>سایت Westmetall</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{data?.metals.map((m) => {
|
||||
const style = METAL_COLORS[m.metal] || {
|
||||
border: "border-border",
|
||||
bg: "from-card to-card",
|
||||
text: "text-primary",
|
||||
badge: "bg-primary/10 text-primary",
|
||||
};
|
||||
const isBackwardation = m.spread < 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.metal}
|
||||
className={`relative overflow-hidden rounded-2xl border ${style.border} bg-gradient-to-br ${style.bg} p-5 backdrop-blur-md transition-all duration-200 hover:shadow-md hover:-translate-y-0.5`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-base font-bold text-foreground">{m.name_fa}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{m.metal} • {m.unit}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[11px] font-semibold px-2.5 py-0.5 rounded-full ${
|
||||
isBackwardation
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"
|
||||
}`}
|
||||
title={isBackwardation ? "تقاضای فوری بیشتر از آتی (بکواردیشن)" : "قیمت آتی بیشتر از نقدی (کونتانگو)"}
|
||||
>
|
||||
{m.market_condition}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Prices Section */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 pt-3 border-t border-border/40">
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">تسویه نقدی (Cash)</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
${m.cash_str}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] text-muted-foreground">قرارداد ۳ ماهه (3M)</div>
|
||||
<div className="text-lg font-bold font-mono text-foreground mt-0.5">
|
||||
${m.three_months_str}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spread & Warehouse Stocks */}
|
||||
<div className="mt-3 flex items-center justify-between text-xs pt-2.5 border-t border-border/40 text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUpDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span>اسپرد: </span>
|
||||
<span className={`font-mono font-semibold ${isBackwardation ? "text-amber-500" : "text-emerald-500"}`}>
|
||||
{m.spread_str} $
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title="موجودی انبارهای بورس لندن و تغییرات روزانه">
|
||||
<Warehouse className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="font-mono font-medium text-foreground">{m.stocks_str} تن</span>
|
||||
{m.stocks_change !== 0 && (
|
||||
<span
|
||||
className={`text-[10px] font-mono font-bold px-1 rounded ${
|
||||
m.stocks_change > 0
|
||||
? "text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
|
||||
: "text-rose-600 dark:text-rose-400 bg-rose-500/10"
|
||||
}`}
|
||||
>
|
||||
{m.stocks_change_str}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Main Quotations Table */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 backdrop-blur-xl shadow-sm overflow-hidden">
|
||||
<div className="p-4 sm:p-5 border-b border-border/60 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-primary" />
|
||||
<h2 className="text-base font-semibold text-foreground">جدول جامع مظنههای رسمی LME (Westmetall)</h2>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute right-3 top-2.5 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="جستجو در نمادها و فلزات..."
|
||||
className="w-full rounded-xl border border-border/80 bg-background/80 py-1.5 pr-9 pl-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-border/60 bg-muted/40 text-muted-foreground font-medium">
|
||||
<th className="py-3 px-4">فلز (Metal)</th>
|
||||
<th className="py-3 px-4">تسویه نقدی (Cash)</th>
|
||||
<th className="py-3 px-4">۳ ماهه (3-Months)</th>
|
||||
<th className="py-3 px-4">اسپرد (3M - Cash)</th>
|
||||
<th className="py-3 px-4">وضعیت بازار</th>
|
||||
<th className="py-3 px-4">موجودی انبارها (LME Stocks)</th>
|
||||
<th className="py-3 px-4">تغییرات موجودی</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{filteredMetals.map((m) => {
|
||||
const isBackwardation = m.spread < 0;
|
||||
return (
|
||||
<tr key={m.metal} className="transition-colors hover:bg-muted/30">
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-semibold text-foreground">{m.name_fa}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{m.metal}</div>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono font-bold text-foreground">
|
||||
${m.cash_str} <span className="text-[10px] text-muted-foreground font-normal">/ mt</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono font-bold text-foreground">
|
||||
${m.three_months_str} <span className="text-[10px] text-muted-foreground font-normal">/ mt</span>
|
||||
</td>
|
||||
<td className={`py-3 px-4 font-mono font-semibold ${isBackwardation ? "text-amber-500" : "text-emerald-500"}`}>
|
||||
{m.spread_str} $
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span
|
||||
className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
isBackwardation
|
||||
? "bg-amber-500/15 text-amber-600 dark:text-amber-400"
|
||||
: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{m.market_condition}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono text-foreground font-medium">
|
||||
{m.stocks_str} تن
|
||||
</td>
|
||||
<td className="py-3 px-4 font-mono font-semibold">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 ${
|
||||
m.stocks_change > 0
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: m.stocks_change < 0
|
||||
? "text-rose-600 dark:text-rose-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{m.stocks_change > 0 ? "+" : ""}
|
||||
{m.stocks_change_str} تن
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Side Panels: FX Rates and Precious Metals */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Exchange Rates Panel */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 p-5 backdrop-blur-xl shadow-sm space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">نرخهای برابری ارز LME & ECB (Exchange Rates)</h3>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground font-mono">EUR / USD</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-1">
|
||||
{data?.exchange_rates?.map((fx, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2.5 rounded-xl border border-border/40 bg-background/50 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">{fx.pair}</span>
|
||||
<span className="font-mono font-bold text-foreground text-sm">{fx.rate_str}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Precious Metals Panel */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/60 p-5 backdrop-blur-xl shadow-sm space-y-3">
|
||||
<div className="flex items-center justify-between border-b border-border/50 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground">فلزات گرانبها (Precious Metals London)</h3>
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground font-mono">Gold & Silver</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 pt-1">
|
||||
{data?.precious_metals?.map((pm, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2.5 rounded-xl border border-border/40 bg-background/50 text-xs"
|
||||
>
|
||||
<span className="text-muted-foreground">{pm.name}</span>
|
||||
<span className="font-mono font-bold text-foreground text-sm">{pm.price_str}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ import {
|
|||
Bean,
|
||||
Flower2,
|
||||
Citrus,
|
||||
Globe,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { formatNumber, formatPct, toJalali, type PriceRow } from "@/lib/metals-data";
|
||||
|
|
@ -74,6 +75,7 @@ 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";
|
||||
|
||||
const API = API_BASE; // dev: "" (same-origin via Vite proxy); prod: VITE_API_BASE
|
||||
|
||||
|
|
@ -87,12 +89,13 @@ export const Route = createFileRoute("/dashboard")({
|
|||
component: Dashboard,
|
||||
});
|
||||
|
||||
type View = "domestic" | "commodity" | "crypto" | "metals" | "heatmap" | "steel" | "reports";
|
||||
type View = "domestic" | "commodity" | "metals" | "lme" | "crypto" | "heatmap" | "steel" | "reports";
|
||||
const VIEW_NAV: { key: View; name: string; icon: LucideIcon }[] = [
|
||||
{ key: "domestic", name: "بازار داخلی", icon: Banknote },
|
||||
{ key: "commodity", name: "کامودیتی", icon: Flame },
|
||||
{ key: "crypto", name: "کریپتو", icon: Bitcoin },
|
||||
{ key: "metals", name: "فلزات و فولاد", icon: Factory },
|
||||
{ key: "lme", name: "LME", icon: Globe },
|
||||
{ key: "crypto", name: "کریپتو", icon: Bitcoin },
|
||||
{ key: "heatmap", name: "اقتصاد جهانی", icon: LayoutGrid },
|
||||
{ key: "steel", name: "سهام فولادی", icon: BarChart3 },
|
||||
{ key: "reports", name: "گزارشها", icon: FileText },
|
||||
|
|
@ -211,8 +214,9 @@ function Dashboard() {
|
|||
|
||||
{view === "domestic" && <DomesticView />}
|
||||
{view === "commodity" && <CommodityView />}
|
||||
{view === "crypto" && <CryptoView />}
|
||||
{view === "metals" && <MetalsView jumpTo={jumpTo} onConsumeJump={() => setJumpTo(null)} />}
|
||||
{view === "lme" && <LmeView />}
|
||||
{view === "crypto" && <CryptoView />}
|
||||
{view === "heatmap" && <WorldEconomyView />}
|
||||
{view === "steel" && <SteelStocksView />}
|
||||
{view === "reports" && <ReportsView />}
|
||||
|
|
|
|||
Loading…
Reference in New Issue