3990 lines
143 KiB
TypeScript
3990 lines
143 KiB
TypeScript
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,
|
||
COMMODITY_UNITS,
|
||
faUnit,
|
||
} 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;
|
||
|
||
const faDigits = (s: string | number | null | undefined): string => {
|
||
if (s == null) return "—";
|
||
return String(s).replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
|
||
}; // 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<View>("domestic");
|
||
// selecting a metals product from elsewhere can jump straight to that view
|
||
const [jumpTo, setJumpTo] = useState<string | null>(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 (
|
||
<div className="grid min-h-screen place-items-center bg-background text-foreground">
|
||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen overflow-x-clip bg-background text-foreground pb-28 sm:pb-0">
|
||
<header
|
||
className="glass sticky top-0 z-40"
|
||
style={{ "--glass-blur": "32px", "--glass-bg": "rgba(255,255,255,0.72)" } as CSSProperties}
|
||
>
|
||
<div className="flex items-center gap-3 px-4 sm:px-5 h-14">
|
||
<Link to="/" className="flex items-center gap-2 shrink-0">
|
||
<div className="w-8 h-8 rounded-md bg-primary/15 border border-primary/40 grid place-items-center">
|
||
<Activity className="w-4 h-4 text-primary" />
|
||
</div>
|
||
<div className="leading-tight hidden sm:block">
|
||
<div className="text-sm font-semibold">دیدوان</div>
|
||
<div className="text-[10px] text-muted-foreground">سامانه جامع آمار و اطلاعات</div>
|
||
</div>
|
||
</Link>
|
||
|
||
<nav className="hidden flex-1 sm:flex justify-center">
|
||
<TubelightNavbar
|
||
floating={false}
|
||
items={VIEW_NAV.map((v) => ({ 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);
|
||
}}
|
||
/>
|
||
</nav>
|
||
<div className="flex-1 sm:hidden" />
|
||
|
||
<Link
|
||
to="/profile"
|
||
className="shrink-0 grid place-items-center h-9 w-9 rounded-md border border-border text-muted-foreground transition hover:bg-accent hover:text-foreground"
|
||
aria-label="پروفایل"
|
||
title="پروفایل"
|
||
>
|
||
<User className="w-4 h-4" />
|
||
</Link>
|
||
|
||
<button
|
||
onClick={() => {
|
||
fetch(`${API}/api/logout`, { method: "POST", credentials: "include" })
|
||
.catch(() => {})
|
||
.finally(() => navigate({ to: "/login" }));
|
||
}}
|
||
className="shrink-0 grid place-items-center h-9 w-9 rounded-md border border-border text-muted-foreground transition hover:bg-accent hover:text-foreground"
|
||
aria-label="خروج"
|
||
title="خروج"
|
||
>
|
||
<LogOut className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Soft fade under the header so content dissolves into it instead of a hard edge */}
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none fixed inset-x-0 top-14 z-30 h-5 bg-gradient-to-b from-background to-transparent"
|
||
/>
|
||
|
||
{/* Mobile: blur + fade band so content under the bottom navbar is obscured */}
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none fixed inset-x-0 bottom-0 z-40 h-32 backdrop-blur-md sm:hidden bg-gradient-to-t from-background via-background/85 to-transparent [mask-image:linear-gradient(to_top,#000_0,#000_55%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_top,#000_0,#000_55%,transparent_100%)]"
|
||
/>
|
||
|
||
{/* Mobile: navbar pinned to the bottom (avoids header overflow on small screens) */}
|
||
<div className="sm:hidden">
|
||
<TubelightNavbar
|
||
floating
|
||
size="lg"
|
||
items={VIEW_NAV.map((v) => ({ 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);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{view === "domestic" && <DomesticView />}
|
||
{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 />}
|
||
{view === "reports" && <ReportsView />}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─────────────────────────── Domestic (FX) view ─────────────────────────── */
|
||
|
||
type CurRow = {
|
||
name: string;
|
||
price: number;
|
||
change_val: string;
|
||
pct: number;
|
||
dir: "up" | "down" | "";
|
||
fetched_at: string;
|
||
sparkline?: number[];
|
||
};
|
||
|
||
function DomesticView() {
|
||
const [currency, setCurrency] = useState<CurRow[]>([]);
|
||
const [gold, setGold] = useState<CurRow[]>([]);
|
||
const [coin, setCoin] = useState<CurRow[]>([]);
|
||
const [flashes, setFlashes] = useState<Record<string, "up" | "down">>({});
|
||
const [lastUpdate, setLastUpdate] = useState("");
|
||
const prevRef = useRef<Map<string, number>>(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<string, "up" | "down"> = {};
|
||
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, sparkline: r.sparkline })),
|
||
[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 (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6">
|
||
<ViewHeader
|
||
live={ready}
|
||
title="بازار داخلی"
|
||
subtitle="سکه، طلا و ارز — زنده"
|
||
lastUpdate={lastUpdate}
|
||
/>
|
||
|
||
{!ready ? (
|
||
<SkeletonGrid count={12} />
|
||
) : (
|
||
<>
|
||
{kpis.length > 0 && <HeroRow items={kpis} format={(n) => formatNumber(Math.round(n))} />}
|
||
{coin.length > 0 && (
|
||
<Section title="سکه">
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||
{coin.map((r, i) => (
|
||
<CurrencyCard
|
||
key={r.name}
|
||
row={r}
|
||
kind="coin"
|
||
flash={flashes[r.name]}
|
||
featured
|
||
spark
|
||
series={series[r.name]}
|
||
index={i}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
{gold.length > 0 && (
|
||
<Section title="طلا و نقره">
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||
{gold.map((r, i) => (
|
||
<CurrencyCard
|
||
key={r.name}
|
||
row={r}
|
||
kind="gold"
|
||
flash={flashes[r.name]}
|
||
featured
|
||
spark
|
||
series={series[r.name]}
|
||
index={i}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
<Section title="ارزهای اصلی">
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||
{mainCur.map((r, i) => (
|
||
<CurrencyCard
|
||
key={r.name}
|
||
row={r}
|
||
flash={flashes[r.name]}
|
||
spark
|
||
series={series[r.name]}
|
||
index={i}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
{otherCur.length > 0 && (
|
||
<Section title="سایر ارزها">
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-2.5">
|
||
{otherCur.map((r, i) => (
|
||
<CurrencyCard
|
||
key={r.name}
|
||
row={r}
|
||
flash={flashes[r.name]}
|
||
spark
|
||
series={series[r.name]}
|
||
index={i}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<svg {...svgProps} className={cn("block", className)}>
|
||
<line
|
||
x1="0"
|
||
y1={height / 2}
|
||
x2={width}
|
||
y2={height / 2}
|
||
stroke="var(--border)"
|
||
strokeWidth={1.5}
|
||
strokeDasharray="3 4"
|
||
strokeLinecap="round"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<svg {...svgProps} className={cn("block overflow-visible", className)}>
|
||
<defs>
|
||
<linearGradient id={`sg${uid}`} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={color} stopOpacity={fillOpacity} />
|
||
<stop offset="100%" stopColor={color} stopOpacity={0} />
|
||
</linearGradient>
|
||
</defs>
|
||
<path d={area} fill={`url(#sg${uid})`} stroke="none" />
|
||
<path
|
||
d={line}
|
||
fill="none"
|
||
stroke={color}
|
||
strokeWidth={strokeWidth}
|
||
strokeLinejoin="round"
|
||
strokeLinecap="round"
|
||
vectorEffect="non-scaling-stroke"
|
||
/>
|
||
{dot && (
|
||
<g>
|
||
<circle cx={last[0]} cy={last[1]} r={3} fill={color} />
|
||
<circle cx={last[0]} cy={last[1]} r={5.5} fill={color} opacity={0.3} />
|
||
</g>
|
||
)}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/* ── 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 (
|
||
<span
|
||
className="relative inline-block overflow-hidden align-bottom"
|
||
style={{ height: "1.2em" }}
|
||
>
|
||
<AnimatePresence initial={false} mode="popLayout">
|
||
<motion.span
|
||
key={str}
|
||
initial={{ y: "115%" }}
|
||
animate={{ y: "0%" }}
|
||
exit={{ y: "-115%" }}
|
||
transition={{ duration: 0.34, ease: [0.22, 1, 0.36, 1] }}
|
||
className="block whitespace-nowrap"
|
||
dir="ltr"
|
||
>
|
||
{str}
|
||
</motion.span>
|
||
</AnimatePresence>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ── 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 (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-mono font-bold shrink-0 backdrop-blur-sm",
|
||
down ? "bg-red-500/15 text-red-600" : "bg-emerald-500/15 text-emerald-700",
|
||
)}
|
||
>
|
||
{down ? "▼" : "▲"} {faDigits(Math.abs(pct).toFixed(decimals) + "%")}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ── 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 = 28;
|
||
function useRollingSeries(rows: { name: string; price: number; pct?: number; sparkline?: number[] }[]) {
|
||
const [series, setSeries] = useState<Record<string, number[]>>({});
|
||
const ref = useRef<Record<string, number[]>>({});
|
||
useEffect(() => {
|
||
if (!rows.length) return;
|
||
const next = { ...ref.current };
|
||
let changed = false;
|
||
for (const r of rows) {
|
||
if (!Number.isFinite(r.price)) continue;
|
||
// If backend provided real recorded market series from DB, use it directly!
|
||
if (r.sparkline && r.sparkline.length > 1) {
|
||
const existing = next[r.name];
|
||
if (!existing || existing.length !== r.sparkline.length || existing[existing.length - 1] !== r.price) {
|
||
next[r.name] = r.sparkline;
|
||
changed = true;
|
||
}
|
||
continue;
|
||
}
|
||
const cur = next[r.name];
|
||
if (!cur) {
|
||
next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 28);
|
||
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 (
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
{items.map((it, i) => (
|
||
<KpiCard key={it.label} item={it} delay={i * 0.07} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
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 (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 14 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.45, delay, ease: [0.22, 1, 0.36, 1] }}
|
||
className={cn(
|
||
"relative overflow-hidden rounded-2xl border bg-card p-4 sm:p-5 shadow-sm",
|
||
up && "border-emerald-500/30",
|
||
down && "border-red-500/30",
|
||
!up && !down && "border-primary/30",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"pointer-events-none absolute inset-0 opacity-[0.06]",
|
||
up ? "bg-emerald-500" : down ? "bg-red-500" : "bg-primary",
|
||
)}
|
||
/>
|
||
<div className="relative flex items-center justify-between">
|
||
<span className="text-xs font-medium text-muted-foreground truncate">{label}</span>
|
||
{pct != null && pct !== 0 && (
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-mono font-semibold shrink-0",
|
||
up && "bg-emerald-500/15 text-emerald-700",
|
||
down && "bg-red-500/15 text-red-600",
|
||
!up && !down && "bg-primary/15 text-primary",
|
||
)}
|
||
>
|
||
{down ? "▼" : "▲"} {formatPct(pct)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="relative mt-2 flex items-end justify-between gap-3">
|
||
<div className="font-mono text-2xl sm:text-[28px] leading-none font-bold tabular-nums text-foreground">
|
||
<AnimatedNumber value={value} decimals={decimals} />
|
||
{unit && <span className="ml-1 text-xs font-normal text-muted-foreground">{unit}</span>}
|
||
</div>
|
||
<Sparkline data={series} dir={dir} width={108} height={38} strokeWidth={1.8} dot />
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/* ── 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<AccentKey, { grad: string; ring: string; glow: string; spark: string }> = {
|
||
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<string, LucideIcon> = {
|
||
"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 (
|
||
<span
|
||
aria-hidden
|
||
className={cn("fi shrink-0 rounded-full shadow-sm ring-1 ring-black/10", `fi-${meta.flag}`)}
|
||
style={{ width: px, height: px, backgroundSize: "cover", backgroundPosition: "center" }}
|
||
/>
|
||
);
|
||
}
|
||
const a = ACCENT[meta.accent];
|
||
const Icon = meta.Icon ?? Banknote;
|
||
return (
|
||
<span
|
||
aria-hidden
|
||
className={cn(
|
||
"grid shrink-0 place-items-center rounded-full bg-gradient-to-br text-white shadow-sm ring-1",
|
||
a.grad,
|
||
a.ring,
|
||
)}
|
||
style={{ width: px, height: px }}
|
||
>
|
||
<Icon className={size === "lg" ? "h-6 w-6" : "h-[18px] w-[18px]"} strokeWidth={2.2} />
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/* ── 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;
|
||
|
||
|
||
|
||
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 (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 18 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||
className="relative flex h-full min-h-[230px] flex-col justify-between overflow-hidden hero-glass p-6 sm:p-7"
|
||
>
|
||
<div
|
||
className={cn(
|
||
"hero-glow pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full blur-3xl",
|
||
a.glow,
|
||
)}
|
||
/>
|
||
{WatermarkIcon && (
|
||
<WatermarkIcon
|
||
aria-hidden
|
||
className="pointer-events-none absolute -bottom-8 left-2 h-44 w-44 opacity-[0.06]"
|
||
strokeWidth={1.2}
|
||
/>
|
||
)}
|
||
<div className="relative flex items-start justify-between gap-3">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
{meta && <AssetBadge meta={meta} size="lg" />}
|
||
<div className="min-w-0">
|
||
<div className="truncate text-sm font-medium text-muted-foreground">{label}</div>
|
||
<div className="mt-1.5 font-mono text-4xl font-bold leading-none tabular-nums text-foreground sm:text-5xl">
|
||
<Odometer value={value} format={format} />
|
||
{unit && (
|
||
<span className="ml-2 text-sm font-normal text-muted-foreground">{unit}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{pct != null && <DeltaPill pct={pct} dir={dir || (pct < 0 ? "down" : "up")} />}
|
||
</div>
|
||
|
||
<div className="relative -mx-6 my-4 sm:-mx-7">
|
||
<Sparkline
|
||
data={series}
|
||
dir={dir}
|
||
color={a.spark}
|
||
responsive
|
||
height={92}
|
||
strokeWidth={2.4}
|
||
fillOpacity={0.3}
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 14 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.45, delay, ease: [0.22, 1, 0.36, 1] }}
|
||
className="relative flex flex-col justify-between overflow-hidden glass-tile p-4"
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{meta && <AssetBadge meta={meta} />}
|
||
<span className="truncate text-xs font-medium text-muted-foreground">{label}</span>
|
||
</div>
|
||
{pct != null && <DeltaPill pct={pct} dir={dir || (pct < 0 ? "down" : "up")} />}
|
||
</div>
|
||
<div className="mt-2 font-mono text-xl font-bold tabular-nums text-foreground sm:text-2xl">
|
||
<Odometer value={value} format={format} />
|
||
{unit && <span className="ml-1 text-[10px] font-normal text-muted-foreground">{unit}</span>}
|
||
</div>
|
||
<div className="-mx-4 mt-2">
|
||
<Sparkline
|
||
data={series}
|
||
dir={dir}
|
||
color={a.spark}
|
||
responsive
|
||
height={34}
|
||
strokeWidth={1.6}
|
||
fillOpacity={0.4}
|
||
/>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function HeroRow({ items, format }: { items: KpiItem[]; format: HeroFmt }) {
|
||
if (!items.length) return null;
|
||
const [hero, ...rest] = items;
|
||
return (
|
||
<div className="grid grid-cols-1 gap-3 sm:gap-4 lg:grid-cols-3">
|
||
<div className="lg:col-span-2">
|
||
<HeroCard item={hero} format={format} />
|
||
</div>
|
||
{rest.length > 0 && (
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4 lg:grid-cols-1">
|
||
{rest.map((it, i) => (
|
||
<GlassStat key={it.label} item={it} format={format} delay={i * 0.08} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={cn(
|
||
"glass-tile overflow-hidden flex flex-col px-3.5 pt-3 pb-0",
|
||
flash === "up" && "flash-up",
|
||
flash === "down" && "flash-down",
|
||
index != null && "rise-in",
|
||
)}
|
||
style={index != null ? { animationDelay: `${Math.min(index * 22, 340)}ms` } : undefined}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<AssetBadge meta={meta} />
|
||
<DeltaPill pct={row.pct} dir={row.dir} />
|
||
</div>
|
||
<div className="mt-2 text-xs font-semibold leading-snug text-foreground line-clamp-2">
|
||
{row.name}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"mt-1 font-mono font-bold text-foreground tabular-nums",
|
||
featured ? "text-base sm:text-lg" : "text-sm",
|
||
)}
|
||
>
|
||
<Odometer value={row.price} format={(n) => formatNumber(Math.round(n))} />
|
||
<span className="mr-1 text-[10px] font-normal text-muted-foreground">ریال</span>
|
||
</div>
|
||
{spark && series && series.length > 1 ? (
|
||
<div className="-mx-3.5 mt-2">
|
||
<Sparkline
|
||
data={series}
|
||
dir={row.dir}
|
||
color={ACCENT[meta.accent].spark}
|
||
responsive
|
||
height={36}
|
||
strokeWidth={1.6}
|
||
fillOpacity={0.45}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="pb-3" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─────────────────────────── 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<YFRow[]>([]);
|
||
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<string, YFRow[]> = { 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: COMMODITY_UNITS[r.symbol] || faUnit(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 (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6">
|
||
<ViewHeader
|
||
live={rows.length > 0}
|
||
title="کامودیتی"
|
||
subtitle="قیمت لحظهای بازارهای جهانی"
|
||
badge="زنده"
|
||
lastUpdate={lastUpdate}
|
||
/>
|
||
|
||
{rows.length === 0 ? (
|
||
<SkeletonGrid count={10} />
|
||
) : (
|
||
<>
|
||
{kpis.length > 0 && (
|
||
<HeroRow
|
||
items={kpis}
|
||
format={(n) =>
|
||
faDigits(
|
||
n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }),
|
||
)
|
||
}
|
||
/>
|
||
)}
|
||
{COMMODITY_GROUP_ORDER.map((gk) =>
|
||
grouped[gk].length ? (
|
||
<Section key={gk} title={COMMODITY_GROUP_LABELS[gk]}>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||
{grouped[gk].map((row, i) => (
|
||
<CommodityCard
|
||
key={row.symbol}
|
||
row={row}
|
||
group={gk}
|
||
series={series[row.symbol]}
|
||
index={i}
|
||
/>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
) : null,
|
||
)}
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={cn(
|
||
"glass-tile overflow-hidden flex flex-col px-4 pt-3 pb-0",
|
||
index != null && "rise-in",
|
||
)}
|
||
style={index != null ? { animationDelay: `${Math.min(index * 22, 340)}ms` } : undefined}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<AssetBadge meta={meta} />
|
||
<DeltaPill pct={row.pct} dir={dir} />
|
||
</div>
|
||
<div className="mt-2">
|
||
<div className="text-sm font-semibold leading-snug text-foreground line-clamp-2">
|
||
{commodityLabel(row.symbol, row.name)}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground font-mono">{row.symbol}</div>
|
||
</div>
|
||
<div className="mt-1 text-lg font-mono font-bold text-foreground tabular-nums">
|
||
<Odometer
|
||
value={row.price}
|
||
format={(n) => faDigits(n.toLocaleString("en-US", { maximumFractionDigits: 2 }))}
|
||
/>
|
||
<span className="mr-1 text-[10px] font-normal text-muted-foreground">{COMMODITY_UNITS[row.symbol] || faUnit(row.currency)}</span>
|
||
</div>
|
||
{series && series.length > 1 ? (
|
||
<div className="-mx-4 mt-2">
|
||
<Sparkline
|
||
data={series}
|
||
dir={dir}
|
||
color={ACCENT[meta.accent].spark}
|
||
responsive
|
||
height={38}
|
||
strokeWidth={1.6}
|
||
fillOpacity={0.45}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="pb-3" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─────────────────────────── 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<string, string> = {
|
||
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": "🇸🇦",
|
||
};
|
||
|
||
// faDigits declared globally at top
|
||
|
||
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 (
|
||
<div
|
||
style={{ ...dim, background: steelAvatarBg(row.rank) }}
|
||
className="shrink-0 grid place-items-center rounded-full text-[11px] font-bold uppercase text-white"
|
||
>
|
||
{row.name.slice(0, 2)}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<img
|
||
src={`https://companiesmarketcap.com/img/company-logos/64/${ticker}.webp`}
|
||
onError={() => 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<SteelRow[]>([]);
|
||
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) => {
|
||
if (v == null) return "—";
|
||
if (v >= 1000) return `${faDigits((v / 1000).toFixed(2))} تریلیون دلار`;
|
||
return `${faDigits(v.toFixed(2))} میلیارد دلار`;
|
||
};
|
||
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 (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-5">
|
||
<ViewHeader
|
||
live={rows.length > 0}
|
||
title="قیمت سهام شرکتها"
|
||
subtitle={
|
||
`${faDigits(rows.length)} شرکت فولادی · مجموع ارزش بازار ${
|
||
totalCap >= 1000 ? faDigits((totalCap / 1000).toFixed(2)) + " تریلیون دلار" : faDigits(totalCap.toFixed(0)) + " میلیارد دلار"
|
||
}`
|
||
}
|
||
lastUpdate={lastUpdate}
|
||
/>
|
||
|
||
{rows.length > 0 && <KpiBar items={kpis} />}
|
||
|
||
{rows.length === 0 ? (
|
||
<SkeletonGrid count={12} />
|
||
) : (
|
||
<div className="relative panel p-3 sm:p-4">
|
||
<div className="hidden md:grid grid-cols-12 gap-3 px-3 pb-2 text-[11px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||
<div className="col-span-1 text-center">#</div>
|
||
<div className="col-span-3">شرکت</div>
|
||
<div className="col-span-2">کشور</div>
|
||
<div className="col-span-2 text-left">ارزش بازار</div>
|
||
<div className="col-span-2 text-left">قیمت</div>
|
||
<div className="col-span-2 text-center">تغییر امروز</div>
|
||
</div>
|
||
|
||
<motion.div
|
||
className="space-y-2"
|
||
variants={{ visible: { transition: { staggerChildren: 0.035 } } }}
|
||
initial="hidden"
|
||
animate="visible"
|
||
>
|
||
{rows.map((r) => (
|
||
<motion.div
|
||
key={r.code}
|
||
variants={{
|
||
hidden: { opacity: 0, x: -20, filter: "blur(4px)" },
|
||
visible: {
|
||
opacity: 1,
|
||
x: 0,
|
||
filter: "blur(0px)",
|
||
transition: { type: "spring", stiffness: 380, damping: 26 },
|
||
},
|
||
}}
|
||
className="relative overflow-hidden rounded-xl border border-border bg-card"
|
||
>
|
||
<div
|
||
className="pointer-events-none absolute inset-0"
|
||
style={{
|
||
background:
|
||
r.dir === "up"
|
||
? "linear-gradient(to left, oklch(0.6 0.16 150 / 0.15), transparent 32%)"
|
||
: r.dir === "down"
|
||
? "linear-gradient(to left, oklch(0.62 0.2 25 / 0.14), transparent 32%)"
|
||
: "none",
|
||
}}
|
||
/>
|
||
{/* Mobile: clean stacked card */}
|
||
<div className="relative flex flex-col gap-2.5 px-3.5 py-3 md:hidden">
|
||
<div className="flex items-center gap-3">
|
||
<SteelLogo row={r} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="font-semibold text-foreground truncate">{r.name}</div>
|
||
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||
<span className="font-mono shrink-0">{r.code}</span>
|
||
<span className="shrink-0">·</span>
|
||
<span className="shrink-0">{STEEL_FLAGS[r.country] || "🏳️"}</span>
|
||
<span className="truncate">{r.country}</span>
|
||
</div>
|
||
</div>
|
||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||
{r.rank != null ? faDigits("#" + r.rank) : "—"}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-end justify-between gap-2 border-t border-border/60 pt-2.5">
|
||
<div className="flex flex-col">
|
||
<span className="text-[10px] text-muted-foreground">قیمت</span>
|
||
<span className="font-mono text-sm font-bold tabular-nums text-foreground">
|
||
{r.price != null ? usd(r.price) + " دلار" : "—"}
|
||
</span>
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="text-[10px] text-muted-foreground">ارزش بازار</span>
|
||
<span className="font-mono text-sm tabular-nums text-foreground">
|
||
{capStr(r.market_cap)}
|
||
</span>
|
||
</div>
|
||
<span className={cn("font-mono text-sm font-semibold tabular-nums", pctCls(r))}>
|
||
{pctText(r)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Desktop: aligned table row */}
|
||
<div className="relative hidden md:grid grid-cols-12 gap-3 items-center px-3 py-3">
|
||
<div className="col-span-1 text-center text-lg font-bold text-muted-foreground font-mono">
|
||
{r.rank != null ? faDigits(String(r.rank)) : "—"}
|
||
</div>
|
||
<div className="col-span-3 flex items-center gap-2.5 min-w-0">
|
||
<SteelLogo row={r} size={36} />
|
||
<div className="min-w-0">
|
||
<div className="font-medium text-foreground truncate">{r.name}</div>
|
||
<div className="text-[11px] text-muted-foreground font-mono">{r.code}</div>
|
||
</div>
|
||
</div>
|
||
<div className="col-span-2 flex items-center gap-2 text-sm min-w-0">
|
||
<span>{STEEL_FLAGS[r.country] || "🏳️"}</span>
|
||
<span className="text-foreground truncate">{r.country}</span>
|
||
</div>
|
||
<div className="col-span-2 text-left font-mono tabular-nums text-foreground whitespace-nowrap">
|
||
{capStr(r.market_cap)}
|
||
</div>
|
||
<div className="col-span-2 text-left font-mono tabular-nums text-foreground whitespace-nowrap">
|
||
{r.price != null ? usd(r.price) + " دلار" : "—"}
|
||
</div>
|
||
<div className="col-span-2 flex items-center justify-center gap-2">
|
||
<SteelBar pct={r.today_pct} dir={r.dir} />
|
||
<span
|
||
className={cn(
|
||
"text-sm font-mono font-semibold tabular-nums w-16 text-left",
|
||
pctCls(r),
|
||
)}
|
||
>
|
||
{pctText(r)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</motion.div>
|
||
</div>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="hidden lg:flex gap-0.5 items-center">
|
||
{Array.from({ length: 8 }).map((_, i) => (
|
||
<span key={i} className={cn("w-1 h-4 rounded-full", i < filled ? color : "bg-muted/50")} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
type BondRow = {
|
||
symbol: string;
|
||
code: string;
|
||
title: string;
|
||
desc: string;
|
||
country: string;
|
||
flag: string;
|
||
tenor: string;
|
||
yield: number | null;
|
||
change: number;
|
||
change_abs: number;
|
||
open: number | null;
|
||
high: number | null;
|
||
low: number | null;
|
||
perf_w: number | null;
|
||
perf_m: number | null;
|
||
perf_y: number | null;
|
||
sparkline: number[];
|
||
};
|
||
|
||
const COUNTRY_FA: Record<string, { fa: string; flag: string }> = {
|
||
"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<string, { fa: string; desc: string; grad: string; soft: string }> = {
|
||
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<string, string> = {
|
||
"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<string, string> = {
|
||
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<RepIndex | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [sourceFilter, setSourceFilter] = useState<string>("all");
|
||
const [query, setQuery] = useState("");
|
||
const [openCat, setOpenCat] = useState<RepCat | null>(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<RepCat[]>(
|
||
() =>
|
||
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 (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-6">
|
||
<ViewHeader
|
||
live={false}
|
||
title="گزارشها"
|
||
subtitle="آرشیو گزارشهای بازار فلزات و فولاد از منابع معتبر جهانی"
|
||
badge="بهزودی"
|
||
lastUpdate=""
|
||
/>
|
||
<div className="relative grid min-h-[60vh] place-items-center">
|
||
{/* blurred placeholder so the real content is never shown */}
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none absolute inset-0 grid grid-cols-1 gap-3 opacity-40 blur-[3px] sm:grid-cols-2 lg:grid-cols-3"
|
||
>
|
||
{Array.from({ length: 9 }).map((_, i) => (
|
||
<div key={i} className="panel-2 h-28" />
|
||
))}
|
||
</div>
|
||
{/* popup */}
|
||
<div className="relative z-10 max-w-md rounded-2xl border border-primary/30 bg-card/90 px-8 py-10 text-center shadow-xl backdrop-blur">
|
||
<div className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-2xl bg-primary/12 text-primary">
|
||
<Newspaper className="h-7 w-7" />
|
||
</div>
|
||
<h2 className="text-2xl font-bold text-foreground">بهزودی</h2>
|
||
<p className="mt-2 text-sm leading-7 text-muted-foreground">
|
||
بخش گزارشها در حال آمادهسازی است و بهزودی در دسترس قرار میگیرد.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-8">
|
||
<ViewHeader
|
||
live={!!idx?.exists}
|
||
title="گزارشها"
|
||
subtitle="آرشیو گزارشهای بازار فلزات و فولاد از معتبرترین منابع جهانی"
|
||
badge={idx ? `${formatNumber(idx.total)} گزارش` : "..."}
|
||
lastUpdate={featured?.date ? toJalali(featured.date) : ""}
|
||
/>
|
||
|
||
{loading ? (
|
||
<SkeletonGrid count={8} />
|
||
) : !idx?.exists ? (
|
||
<EmptyState text="پوشهٔ گزارشها روی سرور پیدا نشد." />
|
||
) : (
|
||
<>
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<RepStat
|
||
icon={<FileText className="w-4 h-4" />}
|
||
label="کل گزارشها"
|
||
value={formatNumber(idx.total)}
|
||
/>
|
||
<RepStat
|
||
icon={<Activity className="w-4 h-4" />}
|
||
label="منابع"
|
||
value={formatNumber(idx.sources.length)}
|
||
/>
|
||
<RepStat
|
||
icon={<FolderOpen className="w-4 h-4" />}
|
||
label="دستهبندیها"
|
||
value={formatNumber(allCats.length)}
|
||
/>
|
||
<RepStat
|
||
icon={<Calendar className="w-4 h-4" />}
|
||
label="تازهترین"
|
||
value={featured?.date ? toJalali(featured.date) : "—"}
|
||
/>
|
||
</div>
|
||
|
||
{featured && (
|
||
<section className="space-y-3">
|
||
<RepSectionTitle
|
||
icon={<Newspaper className="w-4 h-4" />}
|
||
title="تازهترین گزارشها"
|
||
hint="بهروزترین گزارشهای منتشرشده"
|
||
/>
|
||
<div className="grid lg:grid-cols-2 gap-3">
|
||
<RepFeatured file={featured} />
|
||
<div className="grid sm:grid-cols-2 gap-3">
|
||
{news.map((f, i) => (
|
||
<RepNewsCard key={f.folder + f.name} file={f} index={i} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section className="space-y-3">
|
||
<RepSectionTitle
|
||
icon={<Activity className="w-4 h-4" />}
|
||
title="منابع گزارش"
|
||
hint="برای فیلتر، روی یک منبع بزنید"
|
||
/>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{idx.sources.map((s, i) => (
|
||
<RepSourceCard
|
||
key={s.source}
|
||
src={s}
|
||
index={i}
|
||
active={sourceFilter === s.source}
|
||
onClick={() => setSourceFilter((cur) => (cur === s.source ? "all" : s.source))}
|
||
/>
|
||
))}
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<RepSectionTitle
|
||
icon={<FolderOpen className="w-4 h-4" />}
|
||
title="دستهبندیها"
|
||
hint={`${filteredCats.length} دسته`}
|
||
/>
|
||
<div className="relative">
|
||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<RepChip active={sourceFilter === "all"} onClick={() => setSourceFilter("all")}>
|
||
همه
|
||
</RepChip>
|
||
{idx.sources.map((s) => (
|
||
<RepChip
|
||
key={s.source}
|
||
active={sourceFilter === s.source}
|
||
onClick={() => setSourceFilter(s.source)}
|
||
>
|
||
{srcMeta(s.source).fa}
|
||
</RepChip>
|
||
))}
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{filteredCats.map((c, i) => (
|
||
<RepCategoryCard key={c.folder} cat={c} index={i} onOpen={() => setOpenCat(c)} />
|
||
))}
|
||
</div>
|
||
{filteredCats.length === 0 && <EmptyState text="موردی پیدا نشد." />}
|
||
</section>
|
||
</>
|
||
)}
|
||
|
||
<AnimatePresence>
|
||
{openCat && <RepCategoryModal cat={openCat} onClose={() => setOpenCat(null)} />}
|
||
</AnimatePresence>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function RepStat({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||
return (
|
||
<div className="panel p-3.5 flex items-center gap-3">
|
||
<div className="grid h-9 w-9 place-items-center rounded-lg bg-primary/12 text-primary shrink-0">
|
||
{icon}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] text-muted-foreground truncate">{label}</div>
|
||
<div className="font-bold text-foreground tabular-nums truncate">{value}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RepSectionTitle({ icon, title, hint }: { icon: ReactNode; title: string; hint?: string }) {
|
||
return (
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="grid h-7 w-7 place-items-center rounded-md bg-primary/12 text-primary">
|
||
{icon}
|
||
</span>
|
||
<h2 className="text-base font-bold text-foreground">{title}</h2>
|
||
{hint && <span className="text-xs text-muted-foreground">· {hint}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RepFeatured({ file }: { file: RepFile }) {
|
||
const m = srcMeta(file.source);
|
||
return (
|
||
<a
|
||
href={repFileUrl(file)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className={cn(
|
||
"group relative overflow-hidden rounded-2xl p-5 text-white shadow-md min-h-[180px] flex flex-col justify-between bg-gradient-to-br",
|
||
m.grad,
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/20 px-2.5 py-1 text-[11px] font-semibold backdrop-blur">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-white animate-pulse" /> تازهترین
|
||
</span>
|
||
<span className="text-[11px] font-mono opacity-90">
|
||
{file.date ? toJalali(file.date) : ""}
|
||
</span>
|
||
</div>
|
||
<div className="relative z-10">
|
||
<div className="text-xs leading-relaxed opacity-90">
|
||
{m.fa} · {catFa(file.category)}
|
||
</div>
|
||
<h3 className="mt-1 text-xl font-bold leading-snug line-clamp-2">
|
||
{catFa(file.category)} — {kindFa(file.kind)}
|
||
</h3>
|
||
<div className="mt-3 inline-flex items-center gap-1.5 text-sm font-semibold">
|
||
مشاهدهٔ گزارش
|
||
<ArrowUpRight className="w-4 h-4 transition group-hover:-translate-y-0.5" />
|
||
</div>
|
||
</div>
|
||
<FileText className="pointer-events-none absolute -left-5 -bottom-5 w-28 h-28 opacity-15" />
|
||
</a>
|
||
);
|
||
}
|
||
|
||
function RepNewsCard({ file, index }: { file: RepFile; index: number }) {
|
||
const m = srcMeta(file.source);
|
||
return (
|
||
<a
|
||
href={repFileUrl(file)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="tile rise-in px-4 py-3 flex flex-col gap-2 group"
|
||
style={{ animationDelay: `${Math.min(index * 30, 240)}ms` }}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||
m.soft,
|
||
)}
|
||
>
|
||
{m.fa}
|
||
</span>
|
||
<span className="text-[10px] text-muted-foreground font-mono">
|
||
{file.date ? toJalali(file.date) : "—"}
|
||
</span>
|
||
</div>
|
||
<div className="text-sm font-semibold text-foreground line-clamp-2 leading-snug">
|
||
{catFa(file.category)} — {kindFa(file.kind)}
|
||
</div>
|
||
<div className="mt-auto flex items-center justify-between text-[11px] text-muted-foreground">
|
||
<span>{fmtSize(file.size)}</span>
|
||
<span className="inline-flex items-center gap-1 text-primary font-medium opacity-0 group-hover:opacity-100 transition">
|
||
باز کردن <ExternalLink className="w-3 h-3" />
|
||
</span>
|
||
</div>
|
||
</a>
|
||
);
|
||
}
|
||
|
||
function RepSourceCard({
|
||
src,
|
||
index,
|
||
active,
|
||
onClick,
|
||
}: {
|
||
src: RepSource;
|
||
index: number;
|
||
active: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
const m = srcMeta(src.source);
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={cn(
|
||
"rise-in text-right relative overflow-hidden rounded-2xl border bg-card p-4 transition hover:-translate-y-0.5 hover:shadow-md",
|
||
active ? "border-primary ring-2 ring-primary/30" : "border-border",
|
||
)}
|
||
style={{ animationDelay: `${Math.min(index * 40, 240)}ms` }}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className={cn(
|
||
"grid h-11 w-11 shrink-0 place-items-center rounded-xl text-white text-lg font-bold bg-gradient-to-br",
|
||
m.grad,
|
||
)}
|
||
>
|
||
{m.fa.slice(0, 1)}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="font-bold text-foreground truncate">{m.fa}</div>
|
||
<div className="text-[11px] text-muted-foreground truncate">{m.desc}</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-3 flex items-center justify-between text-xs">
|
||
<span className="text-muted-foreground">
|
||
<b className="text-foreground font-mono">{formatNumber(src.count)}</b> گزارش ·{" "}
|
||
{src.category_count} دسته
|
||
</span>
|
||
<span className="text-muted-foreground font-mono">
|
||
{src.latest ? toJalali(src.latest) : "—"}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function RepChip({
|
||
active,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={cn(
|
||
"rounded-full px-3.5 py-1.5 text-xs font-medium transition border",
|
||
active
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "bg-card text-muted-foreground border-border hover:text-foreground hover:border-primary/40",
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function RepCategoryCard({
|
||
cat,
|
||
index,
|
||
onOpen,
|
||
}: {
|
||
cat: RepCat;
|
||
index: number;
|
||
onOpen: () => void;
|
||
}) {
|
||
const m = srcMeta(cat.source);
|
||
return (
|
||
<button
|
||
onClick={onOpen}
|
||
className="rise-in text-right tile px-4 py-3.5 flex flex-col gap-2 group"
|
||
style={{ animationDelay: `${Math.min(index * 30, 300)}ms` }}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="font-bold text-foreground truncate">{catFa(cat.category)}</div>
|
||
<span
|
||
className={cn(
|
||
"mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold",
|
||
m.soft,
|
||
)}
|
||
>
|
||
{m.fa}
|
||
</span>
|
||
</div>
|
||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary">
|
||
<FolderOpen className="w-4 h-4" />
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>
|
||
<b className="text-foreground font-mono">{formatNumber(cat.count)}</b> گزارش
|
||
</span>
|
||
<span className="font-mono">{cat.latest ? toJalali(cat.latest) : "—"}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 text-[11px] text-primary font-medium opacity-0 group-hover:opacity-100 transition">
|
||
مشاهدهٔ فهرست <ChevronLeft className="w-3.5 h-3.5" />
|
||
</div>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function RepCategoryModal({ cat, onClose }: { cat: RepCat; onClose: () => void }) {
|
||
const [files, setFiles] = useState<RepFile[] | null>(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 (
|
||
<motion.div
|
||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/50 backdrop-blur-sm sm:p-4"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
onClick={onClose}
|
||
>
|
||
<motion.div
|
||
className="w-full sm:max-w-2xl max-h-[88vh] bg-card rounded-t-2xl sm:rounded-2xl border border-border shadow-2xl flex flex-col overflow-hidden"
|
||
initial={{ y: 30, opacity: 0, scale: 0.98 }}
|
||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||
exit={{ y: 20, opacity: 0 }}
|
||
transition={{ type: "spring", stiffness: 320, damping: 30 }}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<div className={cn("p-4 text-white bg-gradient-to-br", m.grad)}>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<div className="text-[11px] opacity-90">{m.fa}</div>
|
||
<h3 className="text-lg font-bold">{catFa(cat.category)}</h3>
|
||
<div className="text-xs opacity-90 mt-0.5">{formatNumber(cat.count)} گزارش</div>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="rounded-lg bg-white/20 p-1.5 hover:bg-white/30 transition"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="p-3 border-b border-border">
|
||
<div className="relative">
|
||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<input
|
||
value={q}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="overflow-y-auto p-2 flex-1">
|
||
{!files ? (
|
||
<div className="p-8 grid place-items-center text-muted-foreground">
|
||
<Loader2 className="w-6 h-6 animate-spin" />
|
||
</div>
|
||
) : shown.length === 0 ? (
|
||
<EmptyState text="گزارشی یافت نشد." />
|
||
) : (
|
||
<div className="space-y-1.5">
|
||
{shown.map((f) => (
|
||
<div
|
||
key={f.name}
|
||
className="flex items-center gap-3 rounded-lg border border-border/60 bg-card px-3 py-2.5 hover:border-primary/40 hover:bg-accent/40 transition"
|
||
>
|
||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-lg bg-red-500/10 text-red-600">
|
||
<FileText className="w-4 h-4" />
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-xs font-medium text-foreground truncate" dir="ltr">
|
||
{f.name}
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground font-mono mt-0.5">
|
||
<span>{f.date ? toJalali(f.date) : "—"}</span>
|
||
<span>·</span>
|
||
<span>{fmtSize(f.size)}</span>
|
||
</div>
|
||
</div>
|
||
<a
|
||
href={repFileUrl(f)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="shrink-0 inline-flex items-center gap-1 rounded-lg bg-primary/10 text-primary px-3 py-1.5 text-xs font-semibold hover:bg-primary/20 transition"
|
||
>
|
||
باز کردن <ExternalLink className="w-3.5 h-3.5" />
|
||
</a>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
/* ─────────────────────────── 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)) + " تریلیون دلار";
|
||
if (n >= 1e9) return faDigits((n / 1e9).toFixed(2)) + " میلیارد دلار";
|
||
if (n >= 1e6) return faDigits((n / 1e6).toFixed(2)) + " میلیون دلار";
|
||
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<CryptoPayload | null>(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 (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-7">
|
||
<ViewHeader
|
||
live={coins.length > 0}
|
||
title="کریپتو"
|
||
subtitle="بازار ارزهای دیجیتال — نرخ لحظهای"
|
||
lastUpdate={data?.fetched_at ? new Date(data.fetched_at).toLocaleTimeString("fa-IR") : ""}
|
||
/>
|
||
|
||
{loading && !data ? (
|
||
<SkeletonGrid count={12} />
|
||
) : coins.length === 0 ? (
|
||
<EmptyState text="دادهای دریافت نشد." />
|
||
) : (
|
||
<>
|
||
{g && (
|
||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||
<CryptoStat
|
||
label="ارزش کل بازار"
|
||
value={usdAbbr(g.market_cap_usd)}
|
||
change={g.market_cap_change_24h}
|
||
/>
|
||
<CryptoStat label="حجم ۲۴ ساعت" value={usdAbbr(g.volume_usd)} />
|
||
<CryptoStat
|
||
label="دامیننس بیتکوین"
|
||
value={g.btc_dominance != null ? faDigits(g.btc_dominance.toFixed(1) + "%") : "—"}
|
||
/>
|
||
<CryptoStat
|
||
label="دامیننس اتریوم"
|
||
value={g.eth_dominance != null ? faDigits(g.eth_dominance.toFixed(1) + "%") : "—"}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||
{featured.map((c, i) => (
|
||
<CryptoKpiCard key={c.id} coin={c} delay={i * 0.07} />
|
||
))}
|
||
</div>
|
||
|
||
{trending.length > 0 && (
|
||
<section className="space-y-3">
|
||
<RepSectionTitle
|
||
icon={<Flame className="w-4 h-4" />}
|
||
title="ترند امروز"
|
||
hint="پرجستجوترین کوینها"
|
||
/>
|
||
<div className="flex gap-2.5 overflow-x-auto pb-1">
|
||
{trending.map((t) => (
|
||
<TrendingChip key={t.id} coin={t} />
|
||
))}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section className="space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<RepSectionTitle
|
||
icon={<Coins className="w-4 h-4" />}
|
||
title="بازار"
|
||
hint={`${filtered.length} کوین`}
|
||
/>
|
||
<div className="relative">
|
||
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<input
|
||
value={query}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{filtered.map((c, i) => (
|
||
<CoinCard key={c.id} coin={c} index={i} />
|
||
))}
|
||
</div>
|
||
{filtered.length === 0 && <EmptyState text="کوینی پیدا نشد." />}
|
||
</section>
|
||
</>
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function CryptoStat({
|
||
label,
|
||
value,
|
||
change,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
change?: number | null;
|
||
}) {
|
||
return (
|
||
<div className="panel p-3.5">
|
||
<div className="text-[11px] text-muted-foreground truncate">{label}</div>
|
||
<div className="mt-1 flex items-baseline gap-2 flex-wrap">
|
||
<span className="font-bold text-foreground tabular-nums">{value}</span>
|
||
{change != null && (
|
||
<span className={cn("text-[11px] font-mono font-semibold", pctClass(change))}>
|
||
{change >= 0 ? "▲" : "▼"} {faDigits(Math.abs(change).toFixed(2) + "%")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CryptoKpiCard({ coin, delay }: { coin: CoinRow; delay: number }) {
|
||
const up = (coin.pct_24h ?? 0) >= 0;
|
||
return (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 14 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.45, delay, ease: [0.22, 1, 0.36, 1] }}
|
||
className={cn(
|
||
"relative overflow-hidden rounded-2xl border bg-card p-4 sm:p-5 shadow-sm",
|
||
up ? "border-emerald-500/30" : "border-red-500/30",
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
"pointer-events-none absolute inset-0 opacity-[0.06]",
|
||
up ? "bg-emerald-500" : "bg-red-500",
|
||
)}
|
||
/>
|
||
<div className="relative flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<img src={coin.image} alt="" className="w-7 h-7 rounded-full shrink-0" loading="lazy" />
|
||
<div className="min-w-0">
|
||
<div className="text-sm font-bold text-foreground truncate">{coin.name}</div>
|
||
<div className="text-[10px] text-muted-foreground font-mono">{coin.symbol}</div>
|
||
</div>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"inline-flex items-center gap-0.5 rounded-full px-2 py-0.5 text-[11px] font-mono font-semibold shrink-0",
|
||
up ? "bg-emerald-500/15 text-emerald-700" : "bg-red-500/15 text-red-600",
|
||
)}
|
||
>
|
||
{pctStr(coin.pct_24h)}
|
||
</span>
|
||
</div>
|
||
<div className="relative mt-2 flex items-end justify-between gap-3">
|
||
<div className="font-mono text-2xl sm:text-[26px] leading-none font-bold tabular-nums text-foreground">
|
||
{cryptoPrice(coin.price)}
|
||
</div>
|
||
<Sparkline
|
||
data={coin.sparkline}
|
||
dir={(coin.pct_7d ?? 0) >= 0 ? "up" : "down"}
|
||
width={108}
|
||
height={38}
|
||
strokeWidth={1.8}
|
||
dot
|
||
/>
|
||
</div>
|
||
<div className="relative mt-2 text-[11px] text-muted-foreground">
|
||
مارکتکپ {usdAbbr(coin.market_cap)}
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
function CoinCard({ coin, index }: { coin: CoinRow; index: number }) {
|
||
const sdir: "up" | "down" = (coin.pct_7d ?? 0) >= 0 ? "up" : "down";
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"tile overflow-hidden rise-in flex flex-col px-4 pt-3 pb-0",
|
||
coin.dir === "up" && "tile-up",
|
||
coin.dir === "down" && "tile-down",
|
||
)}
|
||
style={{ animationDelay: `${Math.min(index * 18, 360)}ms` }}
|
||
>
|
||
<div className="flex items-center gap-2.5">
|
||
<span className="text-[10px] text-muted-foreground font-mono w-5 shrink-0 text-center">
|
||
{coin.rank != null ? faDigits(String(coin.rank)) : "—"}
|
||
</span>
|
||
<img src={coin.image} alt="" className="w-6 h-6 rounded-full shrink-0" loading="lazy" />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-semibold text-foreground truncate">{coin.name}</div>
|
||
<div className="text-[10px] text-muted-foreground font-mono">{coin.symbol}</div>
|
||
</div>
|
||
<DeltaPill pct={coin.pct_24h} dir={coin.dir} />
|
||
</div>
|
||
<div className="mt-2 flex items-end justify-between gap-2">
|
||
<div className="text-base font-mono font-bold text-foreground tabular-nums">
|
||
<Odometer value={coin.price ?? 0} format={(n) => cryptoPrice(n)} />
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground font-mono text-left leading-tight">
|
||
<div>
|
||
۱h{" "}
|
||
{coin.pct_1h != null
|
||
? faDigits((coin.pct_1h >= 0 ? "+" : "") + coin.pct_1h.toFixed(1) + "%")
|
||
: "—"}
|
||
</div>
|
||
<div>{usdAbbr(coin.market_cap)}</div>
|
||
</div>
|
||
</div>
|
||
{coin.sparkline && coin.sparkline.length > 1 ? (
|
||
<div className="-mx-4 mt-2">
|
||
<Sparkline
|
||
data={coin.sparkline}
|
||
dir={sdir}
|
||
responsive
|
||
height={34}
|
||
strokeWidth={1.4}
|
||
fillOpacity={0.5}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="pb-3" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TrendingChip({ coin }: { coin: TrendingCoin }) {
|
||
const up = (coin.pct_24h ?? 0) >= 0;
|
||
return (
|
||
<div className="shrink-0 flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2">
|
||
{coin.thumb && (
|
||
<img src={coin.thumb} alt="" className="w-5 h-5 rounded-full" loading="lazy" />
|
||
)}
|
||
<span className="text-xs font-semibold text-foreground">{coin.symbol}</span>
|
||
{coin.pct_24h != null && (
|
||
<span
|
||
className={cn(
|
||
"text-[11px] font-mono font-semibold",
|
||
up ? "text-emerald-700" : "text-red-600",
|
||
)}
|
||
>
|
||
{up ? "▲" : "▼"}
|
||
{faDigits(Math.abs(coin.pct_24h).toFixed(1) + "%")}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WorldEconomyView() {
|
||
const [subTab, setSubTab] = useState<"macro" | "bonds">("macro");
|
||
const [rows, setRows] = useState<WorldRow[]>([]);
|
||
const [bonds, setBonds] = useState<BondRow[]>([]);
|
||
const [lastUpdate, setLastUpdate] = useState("");
|
||
|
||
useEffect(() => {
|
||
let active = true;
|
||
const load = async () => {
|
||
try {
|
||
const [macroData, bondsData] = await Promise.all([
|
||
fetch(`${API}/api/world-economy`, { cache: "no-store" }).then((r) => r.json()).catch(() => []),
|
||
fetch(`${API}/api/bonds`, { cache: "no-store" }).then((r) => r.json()).catch(() => [])
|
||
]);
|
||
if (!active) return;
|
||
if (Array.isArray(macroData) && macroData.length > 0) setRows(macroData);
|
||
if (Array.isArray(bondsData) && bondsData.length > 0) setBonds(bondsData);
|
||
setLastUpdate(new Date().toLocaleTimeString("fa-IR"));
|
||
} catch {
|
||
/* keep last good data */
|
||
}
|
||
};
|
||
load();
|
||
const id = setInterval(load, 60_000);
|
||
return () => {
|
||
active = false;
|
||
clearInterval(id);
|
||
};
|
||
}, []);
|
||
|
||
const ranges = useMemo(() => {
|
||
const m: Record<string, { min: number; max: number }> = {};
|
||
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 faDigits((val / 1000).toFixed(2) + " تریلیون دلار");
|
||
return faDigits(formatNumber(val) + (col.suffix || ""));
|
||
};
|
||
|
||
const us10y = bonds.find((b) => b.code === "US10Y");
|
||
const us02y = bonds.find((b) => b.code === "US02Y");
|
||
const de10y = bonds.find((b) => b.code === "DE10Y");
|
||
const gb10y = bonds.find((b) => b.code === "GB10Y");
|
||
|
||
const spread10_2 = us10y?.yield && us02y?.yield ? (us10y.yield - us02y.yield) : null;
|
||
|
||
return (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-5">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||
<ViewHeader
|
||
live={rows.length > 0 || bonds.length > 0}
|
||
title="اقتصاد جهانی و اوراق قرضه"
|
||
subtitle={subTab === "macro" ? "شاخصهای کلانِ اقتصادهای بزرگ دنیا" : "بازار اوراق قرضه دولتی و خزانهداری"}
|
||
lastUpdate={lastUpdate}
|
||
/>
|
||
<div className="flex items-center gap-1 bg-secondary/70 border border-border rounded-lg p-1 w-fit self-start sm:self-auto">
|
||
<button
|
||
onClick={() => setSubTab("macro")}
|
||
className={cn(
|
||
"px-3.5 h-8 rounded-md text-xs font-semibold transition",
|
||
subTab === "macro" ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
شاخصهای کلان اقتصادی
|
||
</button>
|
||
<button
|
||
onClick={() => setSubTab("bonds")}
|
||
className={cn(
|
||
"px-3.5 h-8 rounded-md text-xs font-semibold transition",
|
||
subTab === "bonds" ? "bg-primary text-primary-foreground shadow" : "text-muted-foreground hover:text-foreground"
|
||
)}
|
||
>
|
||
اوراق قرضه دولتی (Bonds)
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{subTab === "macro" ? (
|
||
rows.length === 0 ? (
|
||
<SkeletonGrid count={12} />
|
||
) : (
|
||
<div className="panel overflow-x-auto">
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-border">
|
||
<th className="text-right p-3 font-semibold sticky right-0 bg-card z-10">کشور</th>
|
||
{WE_COLS.map((c) => (
|
||
<th
|
||
key={c.key}
|
||
className="p-3 font-semibold text-muted-foreground text-xs whitespace-nowrap"
|
||
>
|
||
{c.label}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((r) => (
|
||
<tr key={r.country} className="border-b border-border/60">
|
||
<td className="p-2.5 font-medium whitespace-nowrap sticky right-0 bg-card z-10">
|
||
<span className="ml-1.5">{COUNTRY_FA[r.country]?.flag || "🏳️"}</span>
|
||
{COUNTRY_FA[r.country]?.fa || r.country}
|
||
</td>
|
||
{WE_COLS.map((c) => {
|
||
const v = r[c.key] as number | null;
|
||
return (
|
||
<td
|
||
key={c.key}
|
||
className={cn(
|
||
"p-2.5 text-center tabular-nums whitespace-nowrap font-medium",
|
||
v == null ? "text-muted-foreground/40" : "text-foreground",
|
||
)}
|
||
style={cellStyle(c, v)}
|
||
>
|
||
{fmtCell(c, v)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
) : (
|
||
/* Bonds view from TradingView */
|
||
bonds.length === 0 ? (
|
||
<SkeletonGrid count={12} />
|
||
) : (
|
||
<div className="space-y-5">
|
||
{/* Top Yield KPIs */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||
<div className="panel p-3.5 flex flex-col justify-between">
|
||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>🇺🇸 آمریکا ۱۰ ساله (بنچمارک)</span>
|
||
<span className="font-mono text-[10px]">US10Y</span>
|
||
</div>
|
||
<div className="mt-2 flex items-baseline justify-between">
|
||
<span className="text-2xl font-bold font-mono tabular-nums text-foreground">
|
||
{us10y?.yield ? faDigits(us10y.yield.toFixed(3) + "%") : "—"}
|
||
</span>
|
||
{us10y && <DeltaPill pct={us10y.change} dir={us10y.change >= 0 ? "up" : "down"} />}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
|
||
تغییر: {us10y ? faDigits((us10y.change_abs >= 0 ? "+" : "") + (us10y.change_abs * 100).toFixed(1) + " نقطه پایه") : "—"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel p-3.5 flex flex-col justify-between">
|
||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>🇩🇪 آلمان ۱۰ ساله (Bund)</span>
|
||
<span className="font-mono text-[10px]">DE10Y</span>
|
||
</div>
|
||
<div className="mt-2 flex items-baseline justify-between">
|
||
<span className="text-2xl font-bold font-mono tabular-nums text-foreground">
|
||
{de10y?.yield ? faDigits(de10y.yield.toFixed(3) + "%") : "—"}
|
||
</span>
|
||
{de10y && <DeltaPill pct={de10y.change} dir={de10y.change >= 0 ? "up" : "down"} />}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
|
||
تغییر: {de10y ? faDigits((de10y.change_abs >= 0 ? "+" : "") + (de10y.change_abs * 100).toFixed(1) + " نقطه پایه") : "—"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel p-3.5 flex flex-col justify-between">
|
||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>🇬🇧 انگلیس ۱۰ ساله (Gilt)</span>
|
||
<span className="font-mono text-[10px]">GB10Y</span>
|
||
</div>
|
||
<div className="mt-2 flex items-baseline justify-between">
|
||
<span className="text-2xl font-bold font-mono tabular-nums text-foreground">
|
||
{gb10y?.yield ? faDigits(gb10y.yield.toFixed(3) + "%") : "—"}
|
||
</span>
|
||
{gb10y && <DeltaPill pct={gb10y.change} dir={gb10y.change >= 0 ? "up" : "down"} />}
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-muted-foreground font-mono">
|
||
تغییر: {gb10y ? faDigits((gb10y.change_abs >= 0 ? "+" : "") + (gb10y.change_abs * 100).toFixed(1) + " نقطه پایه") : "—"}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="panel p-3.5 flex flex-col justify-between">
|
||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||
<span>📐 اسپرد منحنی ۱۰-۲ سال آمریکا</span>
|
||
<span className="font-mono text-[10px]">10Y - 2Y</span>
|
||
</div>
|
||
<div className="mt-2 flex items-baseline justify-between">
|
||
<span className={cn("text-2xl font-bold font-mono tabular-nums", (spread10_2 ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
|
||
{spread10_2 != null ? faDigits((spread10_2 >= 0 ? "+" : "") + (spread10_2 * 100).toFixed(1) + " نقطه پایه") : "—"}
|
||
</span>
|
||
<span className="text-[11px] px-2 py-0.5 rounded-full font-mono font-medium bg-muted text-muted-foreground">
|
||
{(spread10_2 ?? 0) >= 0 ? "نرمال" : "وارونه (Inverted)"}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||
{spread10_2 != null && spread10_2 < 0 ? "⚠️ هشدار رکود اقتصادی" : "شیب صعودی منحنی بازدهی"}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bonds Table */}
|
||
<div className="panel overflow-x-auto">
|
||
<table className="w-full text-sm border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||
<th className="text-right p-3 font-semibold">اوراق قرضه دولتی</th>
|
||
<th className="p-3 font-semibold text-center">سررسید</th>
|
||
<th className="p-3 font-semibold text-center">نرخ بازدهی (Yield)</th>
|
||
<th className="p-3 font-semibold text-center">تغییر روزانه</th>
|
||
<th className="p-3 font-semibold text-center">تغییر (نقطه پایه)</th>
|
||
<th className="p-3 font-semibold text-center">کف / سقف امروز</th>
|
||
<th className="p-3 font-semibold text-center">بازدهی هفتگی</th>
|
||
<th className="p-3 font-semibold text-center">بازدهی ۱ ماهه</th>
|
||
<th className="p-3 font-semibold text-center">بازدهی ۱ ساله</th>
|
||
<th className="p-3 font-semibold text-center w-28">روند</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{bonds.map((b) => {
|
||
const isUp = b.change >= 0;
|
||
return (
|
||
<tr key={b.symbol} className="border-b border-border/60 hover:bg-muted/40 transition">
|
||
<td className="p-3 font-medium">
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-lg">{b.flag}</span>
|
||
<div>
|
||
<div className="font-semibold text-foreground">{b.title}</div>
|
||
<div className="text-[11px] font-mono text-muted-foreground">{b.code} · {b.country}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="p-3 text-center font-mono text-xs text-muted-foreground">
|
||
<span className="px-2 py-0.5 rounded bg-muted/80">{faDigits(b.tenor)}</span>
|
||
</td>
|
||
<td className="p-3 text-center font-mono font-bold text-base text-foreground tabular-nums">
|
||
{b.yield != null ? faDigits(b.yield.toFixed(3) + "%") : "—"}
|
||
</td>
|
||
<td className="p-3 text-center font-mono text-xs tabular-nums">
|
||
<DeltaPill pct={b.change} dir={isUp ? "up" : "down"} />
|
||
</td>
|
||
<td className="p-3 text-center font-mono text-xs tabular-nums text-foreground">
|
||
{b.change_abs != null ? faDigits((b.change_abs >= 0 ? "+" : "") + (b.change_abs * 100).toFixed(1)) : "—"}
|
||
</td>
|
||
<td className="p-3 text-center font-mono text-xs text-muted-foreground tabular-nums">
|
||
{b.low != null && b.high != null ? faDigits(`${b.low.toFixed(2)} - ${b.high.toFixed(2)}`) : "—"}
|
||
</td>
|
||
<td className={cn("p-3 text-center font-mono text-xs tabular-nums", (b.perf_w ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
|
||
{b.perf_w != null ? faDigits((b.perf_w >= 0 ? "+" : "") + b.perf_w.toFixed(2) + "%") : "—"}
|
||
</td>
|
||
<td className={cn("p-3 text-center font-mono text-xs tabular-nums", (b.perf_m ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
|
||
{b.perf_m != null ? faDigits((b.perf_m >= 0 ? "+" : "") + b.perf_m.toFixed(2) + "%") : "—"}
|
||
</td>
|
||
<td className={cn("p-3 text-center font-mono text-xs tabular-nums", (b.perf_y ?? 0) >= 0 ? "text-emerald-700" : "text-red-600")}>
|
||
{b.perf_y != null ? faDigits((b.perf_y >= 0 ? "+" : "") + b.perf_y.toFixed(2) + "%") : "—"}
|
||
</td>
|
||
<td className="p-3 text-center w-28">
|
||
{b.sparkline && b.sparkline.length > 1 && (
|
||
<div className="w-24 h-7 mx-auto">
|
||
<Sparkline
|
||
data={b.sparkline}
|
||
dir={isUp ? "up" : "down"}
|
||
width={96}
|
||
height={28}
|
||
strokeWidth={1.5}
|
||
fillOpacity={0.25}
|
||
/>
|
||
</div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
)}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
/* ─────────────────────────── 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<string, Record<string, string[]>>;
|
||
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<string, string> = {
|
||
...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<Tree>({});
|
||
const [selected, setSelected] = useState<string | null>(null);
|
||
const [activeHistory, setActiveHistory] = useState<PriceRow[]>([]);
|
||
const [compare, setCompare] = useState<string[]>([]);
|
||
const [compareHistories, setCompareHistories] = useState<Record<string, PriceRow[]>>({});
|
||
const [range, setRange] = useState<Range>("all");
|
||
const [search, setSearch] = useState("");
|
||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||
const [openCats, setOpenCats] = useState<Record<string, boolean>>({});
|
||
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<string[]>([]);
|
||
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<string, PriceRow[]> = {};
|
||
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<string>();
|
||
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<string, string | number> = { 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<LatestRow[]>([]);
|
||
const [catGroup, setCatGroup] = useState<string>("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<string, typeof catalog> = {};
|
||
for (const r of catalog) (m[r.group] ||= []).push(r);
|
||
return m;
|
||
}, [catalog]);
|
||
|
||
return (
|
||
<main className="mx-auto max-w-6xl p-4 sm:p-5 space-y-5">
|
||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<div className="min-w-0">
|
||
<div className="text-[11px] uppercase tracking-wider text-muted-foreground font-mono">
|
||
{mode === "compare" ? "حالت مقایسه" : "محصول انتخابشده"}
|
||
</div>
|
||
<h1 className="text-base sm:text-lg font-semibold mt-0.5 max-w-3xl truncate">
|
||
{mode === "compare"
|
||
? `${compare.length} محصول انتخاب شده`
|
||
: selected || "محصولی انتخاب نشده"}
|
||
</h1>
|
||
{mode === "single" && selectedMeta && (
|
||
<div className="text-[11px] text-muted-foreground mt-0.5 font-mono">
|
||
{groupLabel(selectedMeta.group)} › {categoryLabel(selectedMeta.category)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center gap-1 bg-secondary/60 border border-border rounded-md p-0.5">
|
||
<button
|
||
onClick={() => setMode("single")}
|
||
className={cn(
|
||
"flex items-center gap-1.5 px-2.5 h-7 rounded text-xs font-medium transition",
|
||
mode === "single"
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
<LineChartIcon className="w-3.5 h-3.5" /> تکی
|
||
</button>
|
||
<button
|
||
onClick={() => setMode("compare")}
|
||
className={cn(
|
||
"flex items-center gap-1.5 px-2.5 h-7 rounded text-xs font-medium transition",
|
||
mode === "compare"
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
<BarChart3 className="w-3.5 h-3.5" /> مقایسه ({compare.length})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-1 bg-secondary/60 border border-border rounded-md p-0.5 w-fit">
|
||
{RANGES.map((r) => (
|
||
<button
|
||
key={String(r.value)}
|
||
onClick={() => setRange(r.value)}
|
||
className={cn(
|
||
"px-2.5 sm:px-3 h-7 rounded text-xs font-medium",
|
||
range === r.value
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{r.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Catalog — categorized by family, above the chart */}
|
||
<section className="space-y-3">
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<div className="flex items-center gap-1.5 text-[11px] uppercase tracking-wider text-muted-foreground font-mono">
|
||
<Factory className="w-3.5 h-3.5" /> کاتالوگ محصولات · {catalog.length}
|
||
</div>
|
||
<div className="relative">
|
||
<Search className="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
value={search}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
<CatChip
|
||
active={catGroup === "all" && !catWatch}
|
||
onClick={() => {
|
||
setCatGroup("all");
|
||
setCatWatch(false);
|
||
}}
|
||
>
|
||
همه
|
||
</CatChip>
|
||
{watchlist.length > 0 && (
|
||
<CatChip active={catWatch} onClick={() => setCatWatch((w) => !w)}>
|
||
★ واچلیست
|
||
</CatChip>
|
||
)}
|
||
{catGroups.map((k) => (
|
||
<CatChip
|
||
key={k}
|
||
active={catGroup === k && !catWatch}
|
||
onClick={() => {
|
||
setCatGroup(k);
|
||
setCatWatch(false);
|
||
}}
|
||
>
|
||
{groupLabel(k)}
|
||
</CatChip>
|
||
))}
|
||
</div>
|
||
{loadingTree && latest.length === 0 ? (
|
||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||
<Loader2 className="w-5 h-5 animate-spin ml-2" /> در حال بارگذاری…
|
||
</div>
|
||
) : catalog.length === 0 ? (
|
||
<EmptyState text="موردی پیدا نشد." />
|
||
) : catGroup === "all" && !catWatch && !search.trim() ? (
|
||
<div className="space-y-5">
|
||
{catGroups.map((k) => (
|
||
<div key={k} className="space-y-2">
|
||
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
|
||
<span className="h-4 w-1 rounded bg-primary" />
|
||
{groupLabel(k)}
|
||
<span className="text-[11px] font-mono font-normal text-muted-foreground">
|
||
{catalogByGroup[k]?.length ?? 0}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{(catalogByGroup[k] || []).map((row, i) => (
|
||
<MetalCatalogCard
|
||
key={row.title}
|
||
row={row}
|
||
index={i}
|
||
active={
|
||
mode === "single" ? selected === row.title : compare.includes(row.title)
|
||
}
|
||
starred={watchlist.includes(row.title)}
|
||
onPick={() => pick(row.title)}
|
||
onStar={() => toggleWatch(row.title)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{catalog.map((row, i) => (
|
||
<MetalCatalogCard
|
||
key={row.title}
|
||
row={row}
|
||
index={i}
|
||
active={mode === "single" ? selected === row.title : compare.includes(row.title)}
|
||
starred={watchlist.includes(row.title)}
|
||
onPick={() => pick(row.title)}
|
||
onStar={() => toggleWatch(row.title)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{mode === "single" &&
|
||
selected &&
|
||
(loadingHistory ? (
|
||
<ChartSkeleton />
|
||
) : stats ? (
|
||
<>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<StatCard
|
||
label="آخرین قیمت"
|
||
value={formatNumber(stats.last.mid)}
|
||
sub={toJalali(stats.last.date)}
|
||
icon={<Activity className="w-3.5 h-3.5" />}
|
||
/>
|
||
<StatCard
|
||
label="تغییر نسبت به قبل"
|
||
value={formatPct(stats.pct)}
|
||
tone={stats.pct >= 0 ? "up" : "down"}
|
||
icon={
|
||
stats.pct >= 0 ? (
|
||
<TrendingUp className="w-3.5 h-3.5" />
|
||
) : (
|
||
<TrendingDown className="w-3.5 h-3.5" />
|
||
)
|
||
}
|
||
/>
|
||
<StatCard
|
||
label="بازه (کف – سقف)"
|
||
value={`${formatNumber(stats.lo)} – ${formatNumber(stats.hi)}`}
|
||
/>
|
||
<StatCard label="میانگین بازه" value={formatNumber(stats.avg)} />
|
||
</div>
|
||
<div className="panel p-4">
|
||
<div className="text-xs font-semibold text-muted-foreground font-mono mb-3">
|
||
روند قیمت · میانگین با باند کف/سقف
|
||
</div>
|
||
<div className="h-[380px] sm:h-[420px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<ComposedChart
|
||
data={activeHistory.map((r) => ({ ...r, bandWidth: r.high - r.low }))}
|
||
margin={{ top: 10, right: 10, left: 10, bottom: 0 }}
|
||
>
|
||
<defs>
|
||
<linearGradient id="bandGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.25} />
|
||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0.02} />
|
||
</linearGradient>
|
||
<filter id="neonGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||
<feMerge>
|
||
<feMergeNode in="blur" />
|
||
<feMergeNode in="SourceGraphic" />
|
||
</feMerge>
|
||
</filter>
|
||
</defs>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||
<XAxis
|
||
dataKey="date"
|
||
stroke="var(--muted-foreground)"
|
||
fontSize={11}
|
||
tickMargin={8}
|
||
minTickGap={80}
|
||
tickFormatter={(v) => toJalali(v)}
|
||
/>
|
||
<YAxis
|
||
stroke="var(--muted-foreground)"
|
||
fontSize={11}
|
||
domain={["auto", "auto"]}
|
||
tickFormatter={(v) => formatNumber(v as number)}
|
||
width={80}
|
||
orientation="right"
|
||
/>
|
||
<Tooltip content={<ChartTooltip />} />
|
||
<Area
|
||
type="monotone"
|
||
dataKey="low"
|
||
stackId="band"
|
||
stroke="none"
|
||
fill="transparent"
|
||
activeDot={false}
|
||
isAnimationActive={false}
|
||
/>
|
||
<Area
|
||
type="monotone"
|
||
dataKey="bandWidth"
|
||
stackId="band"
|
||
stroke="none"
|
||
fill="url(#bandGrad)"
|
||
activeDot={false}
|
||
isAnimationActive={false}
|
||
/>
|
||
<Line
|
||
type="monotone"
|
||
dataKey="mid"
|
||
stroke="var(--chart-1)"
|
||
strokeWidth={2}
|
||
isAnimationActive={false}
|
||
dot={(props: any) => {
|
||
if (props.index !== activeHistory.length - 1)
|
||
return <g key={props.index} />;
|
||
const { cx, cy } = props;
|
||
return (
|
||
<g key="latest">
|
||
<circle cx={cx} cy={cy} r={12} fill="var(--primary)" opacity={0.15} />
|
||
<circle
|
||
cx={cx}
|
||
cy={cy}
|
||
r={4}
|
||
fill="var(--primary)"
|
||
filter="url(#neonGlow)"
|
||
/>
|
||
<circle cx={cx} cy={cy} r={2} fill="#fff7e6" />
|
||
</g>
|
||
);
|
||
}}
|
||
activeDot={{ r: 4 }}
|
||
/>
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<EmptyState text="دادهای برای این محصول موجود نیست." />
|
||
))}
|
||
|
||
{mode === "compare" &&
|
||
(compare.length === 0 ? (
|
||
<EmptyState text="از سایدبار حداکثر ۵ محصول را برای مقایسه انتخاب کنید." />
|
||
) : (
|
||
<>
|
||
<div className="flex flex-wrap gap-2">
|
||
{compare.map((t, i) => (
|
||
<div key={t} className="flex items-center gap-2 panel-2 pr-2 pl-1 py-1 text-xs">
|
||
<span className="w-2.5 h-2.5 rounded-full" style={{ background: COLORS[i] }} />
|
||
<span className="max-w-[260px] truncate text-foreground">{t}</span>
|
||
<button
|
||
onClick={() => toggleCompare(t)}
|
||
className="hover:text-destructive"
|
||
aria-label="حذف"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="panel p-4">
|
||
<div className="text-xs font-semibold text-muted-foreground font-mono mb-3">
|
||
مقایسه · میانگین قیمت
|
||
</div>
|
||
<div className="h-[420px] sm:h-[480px]">
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<LineChart
|
||
data={compareData}
|
||
margin={{ top: 10, right: 10, left: 10, bottom: 0 }}
|
||
>
|
||
<CartesianGrid stroke="var(--border)" strokeDasharray="3 3" vertical={false} />
|
||
<XAxis
|
||
dataKey="date"
|
||
stroke="var(--muted-foreground)"
|
||
fontSize={11}
|
||
tickMargin={8}
|
||
minTickGap={80}
|
||
tickFormatter={(v) => toJalali(v)}
|
||
/>
|
||
<YAxis
|
||
stroke="var(--muted-foreground)"
|
||
fontSize={11}
|
||
tickFormatter={(v) => formatNumber(v as number)}
|
||
width={80}
|
||
orientation="right"
|
||
/>
|
||
<Tooltip content={<ChartTooltip />} />
|
||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||
{compare.map((t, i) => (
|
||
<Line
|
||
key={t}
|
||
type="monotone"
|
||
dataKey={t}
|
||
stroke={COLORS[i]}
|
||
strokeWidth={2}
|
||
dot={false}
|
||
connectNulls
|
||
/>
|
||
))}
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
</div>
|
||
</>
|
||
))}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
/* ─────────────────────────── Shared UI ─────────────────────────── */
|
||
|
||
function ViewHeader({
|
||
live,
|
||
title,
|
||
subtitle,
|
||
badge,
|
||
lastUpdate,
|
||
}: {
|
||
live: boolean;
|
||
title: string;
|
||
subtitle: string;
|
||
badge?: string;
|
||
lastUpdate: string;
|
||
}) {
|
||
return (
|
||
<div className="flex items-end justify-between flex-wrap gap-3">
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
"w-2.5 h-2.5 rounded-full animate-pulse",
|
||
live ? "bg-emerald-500" : "bg-amber-500",
|
||
)}
|
||
/>
|
||
<h1 className="text-xl sm:text-2xl font-bold text-foreground">{title}</h1>
|
||
{badge && (
|
||
<span className="text-[11px] font-mono px-2 py-0.5 rounded-full bg-secondary text-muted-foreground">
|
||
{badge}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-muted-foreground mt-1">{subtitle}</p>
|
||
</div>
|
||
{lastUpdate && (
|
||
<span className="text-[11px] text-muted-foreground font-mono">
|
||
آخرین بروزرسانی: {lastUpdate}
|
||
</span>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||
return (
|
||
<section>
|
||
<h2 className="text-xs font-semibold text-muted-foreground mb-2.5 flex items-center gap-2">
|
||
<span>{title}</span>
|
||
<span className="h-px flex-1 bg-border" />
|
||
</h2>
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function SkeletonGrid({ count }: { count: number }) {
|
||
return (
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
|
||
{Array.from({ length: count }).map((_, i) => (
|
||
<div key={i} className="panel-2 h-[78px] animate-pulse" />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CatChip({
|
||
active,
|
||
onClick,
|
||
children,
|
||
}: {
|
||
active: boolean;
|
||
onClick: () => void;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
className={cn(
|
||
"rounded-full px-2.5 py-1 text-[11px] font-medium transition border",
|
||
active
|
||
? "bg-primary text-primary-foreground border-primary"
|
||
: "bg-card text-muted-foreground border-border hover:text-foreground hover:border-primary/40",
|
||
)}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
onClick={onPick}
|
||
title={row.title}
|
||
className={cn(
|
||
"tile group relative cursor-pointer overflow-hidden px-4 py-3.5 flex flex-col",
|
||
dir === "up" && "tile-up",
|
||
dir === "down" && "tile-down",
|
||
active && "ring-2 ring-primary/60",
|
||
index != null && "rise-in",
|
||
)}
|
||
style={index != null ? { animationDelay: `${Math.min(index * 14, 300)}ms` } : undefined}
|
||
>
|
||
<button
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
onStar();
|
||
}}
|
||
className="absolute top-3 left-3 opacity-60 hover:opacity-100 transition"
|
||
aria-label={starred ? "حذف از واچلیست" : "افزودن به واچلیست"}
|
||
>
|
||
<Star
|
||
className={cn(
|
||
"w-4 h-4",
|
||
starred ? "text-amber-500 fill-amber-500" : "text-muted-foreground/40",
|
||
)}
|
||
/>
|
||
</button>
|
||
<div className="pl-7">
|
||
<div className="text-sm font-semibold leading-snug text-foreground line-clamp-2">
|
||
{row.title}
|
||
</div>
|
||
<div className="mt-0.5 text-[11px] text-muted-foreground">
|
||
{categoryLabel(row.category)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-auto pt-3 flex items-end justify-between gap-2">
|
||
<div>
|
||
<div className="font-mono text-lg font-bold text-foreground tabular-nums">
|
||
{formatNumber(row.mid)}
|
||
</div>
|
||
{row.date && (
|
||
<div className="mt-0.5 text-[10px] text-muted-foreground font-mono">
|
||
{toJalali(row.date)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<DeltaPill pct={row.pct} dir={dir} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SidebarItem({
|
||
title,
|
||
active,
|
||
starred,
|
||
onPick,
|
||
onStar,
|
||
}: {
|
||
title: string;
|
||
active: boolean;
|
||
starred: boolean;
|
||
onPick: () => void;
|
||
onStar: () => void;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={cn(
|
||
"group flex items-center gap-1 rounded border-r-2 transition",
|
||
active ? "bg-primary/10 border-primary" : "border-transparent hover:bg-accent/60",
|
||
)}
|
||
>
|
||
<button
|
||
onClick={onPick}
|
||
className={cn(
|
||
"flex-1 text-right px-2 py-1.5 text-[12px] leading-snug truncate",
|
||
active
|
||
? "text-foreground font-medium"
|
||
: "text-muted-foreground group-hover:text-foreground",
|
||
)}
|
||
>
|
||
{title}
|
||
</button>
|
||
<button
|
||
onClick={onStar}
|
||
className="shrink-0 px-1.5 py-1 opacity-60 hover:opacity-100"
|
||
aria-label={starred ? "حذف از واچلیست" : "افزودن به واچلیست"}
|
||
>
|
||
<Star
|
||
className={cn(
|
||
"w-3.5 h-3.5",
|
||
starred ? "text-amber-500 fill-amber-500" : "text-muted-foreground",
|
||
)}
|
||
/>
|
||
</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({
|
||
label,
|
||
value,
|
||
sub,
|
||
tone,
|
||
icon,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
sub?: string;
|
||
tone?: "up" | "down";
|
||
icon?: ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="panel p-3.5">
|
||
<div className="flex items-center justify-between text-[11px] uppercase tracking-wider text-muted-foreground font-mono">
|
||
<span>{label}</span>
|
||
{icon}
|
||
</div>
|
||
<div
|
||
className={cn(
|
||
"mt-2 font-mono font-semibold text-lg tabular-nums",
|
||
tone === "up" && "text-emerald-700",
|
||
tone === "down" && "text-red-600",
|
||
)}
|
||
>
|
||
{value}
|
||
</div>
|
||
{sub && <div className="text-[11px] text-muted-foreground mt-1 font-mono">{sub}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChartSkeleton() {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
{Array.from({ length: 4 }).map((_, i) => (
|
||
<div key={i} className="panel p-3.5 h-[84px] animate-pulse" />
|
||
))}
|
||
</div>
|
||
<div className="panel h-[420px] animate-pulse" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState({ text }: { text: string }) {
|
||
return (
|
||
<div className="panel-2 border-dashed p-10 text-center text-sm text-muted-foreground">
|
||
{text}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ChartTooltip({ active, payload, label }: any) {
|
||
if (!active || !payload?.length) return null;
|
||
return (
|
||
<div className="bg-popover border border-border rounded-md shadow-lg p-2.5 text-xs">
|
||
<div className="font-mono text-[11px] text-muted-foreground mb-1.5">{toJalali(label)}</div>
|
||
{payload.map((p: any) => (
|
||
<div key={p.dataKey} className="flex items-center gap-2 py-0.5">
|
||
<span className="w-2 h-2 rounded-full" style={{ background: p.color || p.stroke }} />
|
||
<span className="text-muted-foreground max-w-[240px] truncate">{p.dataKey}:</span>
|
||
<span className="font-mono font-semibold mr-auto text-foreground">
|
||
{formatNumber(p.value)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|