feat(ime): add Iran Mercantile Exchange (بورس فلزات ایران) tab with categories, 1250+ symbols, and historical price charts

This commit is contained in:
alireza 2026-08-26 11:04:33 +03:30
parent 2b22b032c1
commit cf2ab562cc
4 changed files with 777 additions and 1 deletions

132
backend/ime_metals.py Normal file
View File

@ -0,0 +1,132 @@
import urllib.request
import json
import ssl
import time
import threading
from typing import Dict, Any, List, Optional
IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA"
BASE_URL = "https://api.yektazob.com"
_cache_categories = {
"data": None,
"last_fetched": 0
}
_cache_symbols = {}
_lock = threading.Lock()
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
HEADERS = {
"Authorization": f"Bearer {IME_TOKEN}",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]:
now = time.time()
with _lock:
if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds:
return _cache_categories["data"]
url = f"{BASE_URL}/Symbols/IME/Categories/GetAll"
payload = json.dumps({"PageNumber": 1, "PageSize": 1000}).encode('utf-8')
req = urllib.request.Request(url, data=payload, headers=HEADERS)
with urllib.request.urlopen(req, context=ctx, timeout=12) as resp:
raw_cats = json.loads(resp.read().decode('utf-8'))
cleaned = []
for root in raw_cats:
root_title = root.get("title", "")
root_id = root.get("id")
subs = []
for sub in root.get("children", []):
sub_id = sub.get("id")
sub_title = sub.get("title", "")
symbols = []
for s in sub.get("symbols", []):
s_title = s.get("title", "")
parts = [p.strip() for p in s_title.split(" - ") if p.strip()]
product_name = s.get("productName") or (parts[0] if len(parts) > 0 else s_title)
symbol_code = s.get("symbol") or (parts[1] if len(parts) > 1 else "")
manufacturer = s.get("manufacturer") or (parts[2] if len(parts) > 2 else "")
symbols.append({
"id": s.get("id"),
"title": s_title,
"product_name": product_name,
"symbol_code": symbol_code,
"manufacturer": manufacturer,
"is_active": s.get("isActive", True),
"updated_at": s.get("updatedAt", "")
})
subs.append({
"id": sub_id,
"title": sub_title,
"symbols_count": len(symbols),
"symbols": symbols
})
total_symbols = sum(s["symbols_count"] for s in subs)
cleaned.append({
"id": root_id,
"title": root_title,
"subcategories_count": len(subs),
"total_symbols": total_symbols,
"subcategories": subs
})
with _lock:
_cache_categories["data"] = cleaned
_cache_categories["last_fetched"] = now
return cleaned
def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, Any]:
now = time.time()
with _lock:
cached = _cache_symbols.get(symbol_id)
if cached and (now - cached["time"]) < ttl_seconds:
return cached["data"]
url = f"{BASE_URL}/Symbols/IME/Symbols/Get/{symbol_id}"
req = urllib.request.Request(url, headers=HEADERS)
with urllib.request.urlopen(req, context=ctx, timeout=12) as resp:
raw_detail = json.loads(resp.read().decode('utf-8'))
raw_prices = raw_detail.get("symbolPrices", []) or []
sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", ""))
clean_prices = []
for p in sorted_prices:
d_str = p.get("date", "")
date_iso = d_str.split("T")[0] if "T" in d_str else d_str
clean_prices.append({
"id": p.get("id"),
"date": date_iso,
"high": p.get("high"),
"low": p.get("low"),
"mid": p.get("mid")
})
result = {
"id": raw_detail.get("id"),
"cat_id": raw_detail.get("catID"),
"title": raw_detail.get("title"),
"order_no": raw_detail.get("orderNo"),
"small_desc": raw_detail.get("smallDesc"),
"total_points": len(clean_prices),
"latest_price": clean_prices[-1] if clean_prices else None,
"prices": clean_prices
}
with _lock:
_cache_symbols[symbol_id] = {"time": now, "data": result}
return result

View File

@ -251,6 +251,23 @@ def get_crypto(_: object = Depends(require_session)):
import ime_metals
@app.get("/api/ime/categories")
def get_ime_categories_route(_: object = Depends(require_session)):
try:
return ime_metals.get_ime_categories()
except Exception as e:
raise HTTPException(status_code=502, detail=f"IME categories error: {e}")
@app.get("/api/ime/symbol/{symbol_id}")
def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)):
try:
return ime_metals.get_ime_symbol_detail(symbol_id)
except Exception as e:
raise HTTPException(status_code=502, detail=f"IME symbol error: {e}")
@app.get("/api/lme/diagram")
def get_lme_diagram(field: str = Query("WM_Cu_low"), _: object = Depends(require_session)):
try:
@ -448,6 +465,13 @@ def v1_world_economy(): return get_world_economy(None)
@data_api.get("/ime/categories")
def v1_ime_categories(): return ime_metals.get_ime_categories()
@data_api.get("/ime/symbol/{symbol_id}")
def v1_ime_symbol(symbol_id: int): return ime_metals.get_ime_symbol_detail(symbol_id)
@data_api.get("/lme/diagram")
def v1_lme_diagram(field: str = "WM_Cu_low"): return scrape_lme.get_entire_diagram_data(field)

View File

@ -0,0 +1,616 @@
import { useState, useEffect, useMemo } from "react";
import {
Search,
Factory,
Layers,
ChevronDown,
LineChart as LineChartIcon,
X,
Loader2,
Calendar,
Sparkles,
ArrowUpRight,
TrendingUp,
TrendingDown,
Building2,
Hash
} from "lucide-react";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
Tooltip,
CartesianGrid
} from "recharts";
import { API_BASE } from "@/lib/api";
const API = API_BASE;
export interface ImeSymbol {
id: number;
title: string;
product_name: string;
symbol_code: string;
manufacturer: string;
is_active: boolean;
updated_at: string;
}
export interface ImeSubcategory {
id: number;
title: string;
symbols_count: number;
symbols: ImeSymbol[];
}
export interface ImeCategory {
id: number;
title: string;
subcategories_count: number;
total_symbols: number;
subcategories: ImeSubcategory[];
}
export interface ImePricePoint {
id: number;
date: string; // YYYY-MM-DD
high: number;
low: number;
mid: number;
}
export interface ImeSymbolDetail {
id: number;
cat_id: number;
title: string;
order_no?: number;
small_desc?: string;
total_points: number;
latest_price?: ImePricePoint;
prices: ImePricePoint[];
}
type Timeframe = "3M" | "6M" | "1Y" | "ENTIRE";
export function ImeView() {
const [categories, setCategories] = useState<ImeCategory[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [selectedRootId, setSelectedRootId] = useState<number | "ALL">("ALL");
const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL");
const [search, setSearch] = useState<string>("");
// Modal Detail
const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null);
const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null);
const [detailLoading, setDetailLoading] = useState<boolean>(false);
const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE");
// Load all IME categories
useEffect(() => {
let alive = true;
setLoading(true);
fetch(`${API}/api/ime/categories`, { credentials: "include" })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data: ImeCategory[]) => {
if (alive) {
setCategories(data);
setError(null);
}
})
.catch((err: any) => {
if (alive) setError(err.message || "خطا در دریافت اطلاعات بورس فلزات ایران");
})
.finally(() => {
if (alive) setLoading(false);
});
return () => {
alive = false;
};
}, []);
// Fetch symbol detail & price history when modal opens
useEffect(() => {
if (!selectedSymbol) {
setSymbolDetail(null);
return;
}
let alive = true;
setDetailLoading(true);
setTimeframe("ENTIRE");
fetch(`${API}/api/ime/symbol/${selectedSymbol.id}`, { credentials: "include" })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((data: ImeSymbolDetail) => {
if (alive) setSymbolDetail(data);
})
.catch(() => {
if (alive) setSymbolDetail(null);
})
.finally(() => {
if (alive) setDetailLoading(false);
});
return () => {
alive = false;
};
}, [selectedSymbol]);
// Handle escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setSelectedSymbol(null);
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, []);
// Active subcategories based on root category selection
const currentSubcategories = useMemo(() => {
if (selectedRootId === "ALL") {
return [];
}
const cat = categories.find((c) => c.id === selectedRootId);
return cat ? cat.subcategories : [];
}, [categories, selectedRootId]);
// Flattened symbols filtered by root, subcategory, and search query
const filteredSymbols = useMemo(() => {
let list: Array<ImeSymbol & { rootTitle: string; subTitle: string }> = [];
for (const root of categories) {
if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue;
for (const sub of root.subcategories) {
if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue;
for (const sym of sub.symbols) {
list.push({
...sym,
rootTitle: root.title,
subTitle: sub.title
});
}
}
}
if (!search.trim()) return list;
const q = search.toLowerCase().trim();
return list.filter(
(s) =>
s.title.toLowerCase().includes(q) ||
s.product_name.toLowerCase().includes(q) ||
s.symbol_code.toLowerCase().includes(q) ||
s.manufacturer.toLowerCase().includes(q) ||
s.rootTitle.toLowerCase().includes(q) ||
s.subTitle.toLowerCase().includes(q)
);
}, [categories, selectedRootId, selectedSubId, search]);
// Filtered prices for modal chart by timeframe
const chartPoints = useMemo(() => {
if (!symbolDetail?.prices?.length) return [];
const prices = symbolDetail.prices;
const total = prices.length;
let sliceCount = total;
if (timeframe === "3M") sliceCount = 15;
else if (timeframe === "6M") sliceCount = 30;
else if (timeframe === "1Y") sliceCount = 60;
else if (timeframe === "ENTIRE") sliceCount = total;
const start = Math.max(0, total - sliceCount);
return prices.slice(start);
}, [symbolDetail, timeframe]);
// Stats calculation for the symbol modal
const stats = useMemo(() => {
if (!chartPoints.length) return null;
const mids = chartPoints.map((p) => p.mid).filter((v): v is number => typeof v === "number" && v > 0);
if (!mids.length) return null;
const max = Math.max(...mids);
const min = Math.min(...mids);
const avg = mids.reduce((a, b) => a + b, 0) / mids.length;
const first = mids[0];
const last = mids[mids.length - 1];
const diff = last - first;
const pct = first > 0 ? (diff / first) * 100 : 0;
return { max, min, avg, diff, pct, last };
}, [chartPoints]);
return (
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6" dir="rtl">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-border/60 pb-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 grid place-items-center shrink-0">
<Factory className="w-5 h-5 text-primary" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold tracking-tight text-foreground">
بورس فلزات ایران (IME)
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
تابلوی معاملات فیزیکی بورس کالای ایران، نرخهای کشفشده و آرشیو تاریخی
</p>
</div>
</div>
{/* Global Search */}
<div className="relative w-full sm:w-72">
<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>
{error && (
<div className="rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}
{/* Root Category Navigation Tabs */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
<button
onClick={() => {
setSelectedRootId("ALL");
setSelectedSubId("ALL");
}}
className={`px-3.5 py-1.5 text-xs font-semibold rounded-xl whitespace-nowrap transition ${
selectedRootId === "ALL"
? "bg-primary text-primary-foreground shadow-sm"
: "border border-border/60 bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
همه فلزات
</button>
{categories.map((cat) => (
<button
key={cat.id}
onClick={() => {
setSelectedRootId(cat.id);
setSelectedSubId("ALL");
}}
className={`px-3.5 py-1.5 text-xs font-semibold rounded-xl whitespace-nowrap transition flex items-center gap-1.5 ${
selectedRootId === cat.id
? "bg-primary text-primary-foreground shadow-sm"
: "border border-border/60 bg-muted/30 text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<span>{cat.title}</span>
<span className="text-[10px] opacity-70">({cat.total_symbols})</span>
</button>
))}
</div>
{/* Subcategory Pills (when a root is selected) */}
{currentSubcategories.length > 0 && (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar border-t border-border/40 pt-3">
<button
onClick={() => setSelectedSubId("ALL")}
className={`px-3 py-1 text-[11px] rounded-lg whitespace-nowrap transition ${
selectedSubId === "ALL"
? "bg-foreground text-background font-bold shadow-xs"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
همه زیردستهها
</button>
{currentSubcategories.map((sub) => (
<button
key={sub.id}
onClick={() => setSelectedSubId(sub.id)}
className={`px-3 py-1 text-[11px] rounded-lg whitespace-nowrap transition flex items-center gap-1 ${
selectedSubId === sub.id
? "bg-foreground text-background font-bold shadow-xs"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span>{sub.title}</span>
<span className="text-[10px] opacity-60">({sub.symbols_count})</span>
</button>
))}
</div>
)}
{/* Total symbols count indicator */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span>
نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی
</span>
</div>
{/* Loading state */}
{loading ? (
<div className="py-20 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span>در حال دریافت نمادهای بورس فلزات ایران...</span>
</div>
) : filteredSymbols.length === 0 ? (
<div className="py-16 text-center text-muted-foreground text-sm border rounded-2xl border-dashed">
نمادی با این مشخصات یافت نشد.
</div>
) : (
/* Symbols Cards Grid */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSymbols.slice(0, 120).map((sym) => (
<div
key={sym.id}
onClick={() => setSelectedSymbol(sym)}
className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-primary flex flex-col justify-between"
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
{sym.product_name || sym.title}
</div>
{sym.symbol_code && (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-muted/80 text-muted-foreground border border-border/60 shrink-0">
{sym.symbol_code}
</span>
)}
</div>
{sym.manufacturer && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Building2 className="w-3.5 h-3.5 text-primary/70 shrink-0" />
<span className="truncate">{sym.manufacturer}</span>
</div>
)}
</div>
<div className="mt-4 pt-3 border-t border-border/40 flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5">
<span className="text-[11px] px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium">
{sym.subTitle || sym.rootTitle}
</span>
</div>
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform">
<span>نمودار سابقه</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</span>
</div>
</div>
))}
</div>
)}
{/* FULL HISTORICAL MODAL FOR IME SYMBOL */}
{selectedSymbol && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/75 backdrop-blur-md animate-in fade-in duration-200">
<div
className="relative w-full max-w-4xl max-h-[92vh] overflow-y-auto rounded-3xl border border-border/80 bg-background/95 p-5 sm:p-7 shadow-2xl backdrop-blur-2xl space-y-5"
dir="rtl"
>
{/* Modal Header */}
<div className="flex items-start justify-between border-b border-border/60 pb-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl grid place-items-center border shrink-0 bg-primary/10 border-primary/30">
<Factory className="w-6 h-6 text-primary" />
</div>
<div>
<h2 className="text-lg sm:text-xl font-bold text-foreground">
{selectedSymbol.product_name || selectedSymbol.title}
</h2>
<div className="flex flex-wrap items-center gap-2 mt-1">
{selectedSymbol.manufacturer && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Building2 className="w-3 h-3 text-primary/70" />
{selectedSymbol.manufacturer}
</span>
)}
{selectedSymbol.symbol_code && (
<span className="text-xs font-mono bg-muted/60 px-2 py-0.5 rounded text-muted-foreground">
{selectedSymbol.symbol_code}
</span>
)}
</div>
</div>
</div>
<button
onClick={() => setSelectedSymbol(null)}
className="rounded-xl border border-border/60 bg-muted/40 p-2 text-muted-foreground transition hover:bg-muted hover:text-foreground"
aria-label="بستن"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Quick Stats Pills */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">آخرین قیمت (ریال/کیلوگرم)</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.last.toLocaleString()}
</div>
<div className="text-[10px] text-muted-foreground mt-0.5">
{(stats.last / 10).toLocaleString()} تومان
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">بالاترین قیمت دوره</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.max.toLocaleString()}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">پایینترین قیمت دوره</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.min.toLocaleString()}
</div>
</div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">تغییر در بازه انتخابی</div>
<div
className={`text-base sm:text-lg font-bold font-mono mt-0.5 ${
stats.diff >= 0 ? "text-emerald-500" : "text-rose-500"
}`}
>
{stats.diff >= 0 ? "+" : ""}
{stats.diff.toLocaleString()} ({stats.pct.toFixed(1)}%)
</div>
</div>
</div>
)}
{/* Chart Area */}
<div className="rounded-2xl border border-border/60 bg-card/40 p-4 sm:p-5 space-y-4">
{detailLoading ? (
<div className="h-72 flex flex-col items-center justify-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<span>در حال دریافت سابقه قیمتهای معاملاتی بورس کالا...</span>
</div>
) : chartPoints.length === 0 ? (
<div className="h-72 flex items-center justify-center text-muted-foreground text-sm">
اطلاعات قیمتی برای این نماد ثبت نشده است.
</div>
) : (
<div className="h-72 sm:h-80 w-full" dir="ltr">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartPoints} margin={{ top: 10, right: 15, left: 10, bottom: 0 }}>
<defs>
<linearGradient id="imePriceGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3} />
<stop offset="95%" stopColor="#2563eb" stopOpacity={0.0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="2 2" opacity={0.15} vertical={true} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
minTickGap={35}
/>
<YAxis
domain={["auto", "auto"]}
tick={{ fontSize: 10, fill: "var(--muted-foreground)" }}
tickFormatter={(v) => `${(v / 1000).toLocaleString()}k`}
orientation="right"
/>
<Tooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const pt = payload[0].payload as ImePricePoint;
return (
<div className="rounded-xl border border-border/80 bg-card/95 p-3 text-foreground shadow-2xl backdrop-blur-md space-y-1 min-w-[160px]" dir="rtl">
<div className="text-[11px] text-muted-foreground font-mono pb-1 border-b border-border/40">
تاریخ: {pt.date}
</div>
<div className="flex items-center justify-between text-xs pt-1">
<span className="text-muted-foreground">میانگین موزون:</span>
<span className="font-mono font-bold text-primary">
{pt.mid.toLocaleString()} ریال
</span>
</div>
{pt.high !== pt.mid && (
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>بالاترین:</span>
<span className="font-mono">{pt.high.toLocaleString()}</span>
</div>
)}
{pt.low !== pt.mid && (
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>پایینترین:</span>
<span className="font-mono">{pt.low.toLocaleString()}</span>
</div>
)}
</div>
);
}}
/>
<Area
type="monotone"
dataKey="mid"
name="قیمت پایانی"
stroke="#2563eb"
strokeWidth={2}
fill="url(#imePriceGrad)"
dot={{ r: 2 }}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{/* Timeframe selector bar */}
<div className="pt-3 border-t border-border/40 flex flex-wrap items-center justify-center gap-2" dir="rtl">
<button
onClick={() => setTimeframe("3M")}
className={`rounded-xl border px-4 py-1.5 text-xs font-semibold transition ${
timeframe === "3M"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
۳ ماه
</button>
<button
onClick={() => setTimeframe("6M")}
className={`rounded-xl border px-4 py-1.5 text-xs font-semibold transition ${
timeframe === "6M"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
۶ ماه
</button>
<button
onClick={() => setTimeframe("1Y")}
className={`rounded-xl border px-4 py-1.5 text-xs font-semibold transition ${
timeframe === "1Y"
? "border-primary bg-primary text-primary-foreground shadow-sm"
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
۱ سال
</button>
<button
onClick={() => setTimeframe("ENTIRE")}
className={`rounded-xl border px-5 py-1.5 text-xs font-bold transition ${
timeframe === "ENTIRE"
? "border-primary bg-primary text-primary-foreground shadow-md ring-2 ring-primary/20"
: "border-border/80 bg-background/80 text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
کل دادهها (تاریخچه کامل)
</button>
</div>
</div>
</div>
</div>
)}
</main>
);
}

View File

@ -76,6 +76,8 @@ 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
@ -89,12 +91,13 @@ export const Route = createFileRoute("/dashboard")({
component: Dashboard,
});
type View = "domestic" | "commodity" | "metals" | "lme" | "crypto" | "heatmap" | "steel" | "reports";
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 },
@ -216,6 +219,7 @@ function Dashboard() {
{view === "commodity" && <CommodityView />}
{view === "metals" && <MetalsView jumpTo={jumpTo} onConsumeJump={() => setJumpTo(null)} />}
{view === "lme" && <LmeView />}
{view === "ime" && <ImeView />}
{view === "crypto" && <CryptoView />}
{view === "heatmap" && <WorldEconomyView />}
{view === "steel" && <SteelStocksView />}