StatistaAmeri/frontend/src/routes/dashboard.tsx

3810 lines
131 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import {
useMemo,
useState,
useEffect,
useRef,
useId,
type ReactNode,
type CSSProperties,
} from "react";
import {
ResponsiveContainer,
ComposedChart,
Line,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
Legend,
LineChart,
} from "recharts";
import {
Search,
ChevronDown,
ChevronLeft,
TrendingUp,
TrendingDown,
Activity,
BarChart3,
X,
LineChart as LineChartIcon,
Loader2,
Star,
Menu,
Banknote,
Flame,
Factory,
LayoutGrid,
FileText,
Newspaper,
Download,
ExternalLink,
Calendar,
FolderOpen,
ArrowUpRight,
Bitcoin,
Coins,
LogOut,
User,
Droplet,
Fuel,
Gem,
Hexagon,
Wheat,
Sprout,
Coffee,
Candy,
Bean,
Flower2,
Citrus,
Globe,
type LucideIcon,
} from "lucide-react";
import { formatNumber, formatPct, toJalali, type PriceRow } from "@/lib/metals-data";
import {
groupLabel,
categoryLabel,
commodityLabel,
commodityGroup,
COMMODITY_GROUP_LABELS,
COMMODITY_GROUP_ORDER,
} from "@/lib/labels";
import { cn } from "@/lib/utils";
import { API_BASE } from "@/lib/api";
import { motion, AnimatePresence } from "framer-motion";
import { TubelightNavbar } from "@/components/TubelightNavbar";
import { LmeView } from "@/components/LmeView";
import { ImeView } from "@/components/ImeView";
import { Building2 } from "lucide-react";
const API = API_BASE; // dev: "" (same-origin via Vite proxy); prod: VITE_API_BASE
export const Route = createFileRoute("/dashboard")({
head: () => ({
meta: [
{ title: "داشبورد — سامانه جامع آمار و اطلاعات دیدوان" },
{ name: "description", content: "بازار داخلی ارز، کامودیتی زنده و قیمت فلزات و فولاد" },
],
}),
component: Dashboard,
});
type View = "domestic" | "commodity" | "metals" | "lme" | "ime" | "crypto" | "heatmap" | "steel" | "reports";
const VIEW_NAV: { key: View; name: string; icon: LucideIcon }[] = [
{ key: "domestic", name: "بازار داخلی", icon: Banknote },
{ key: "commodity", name: "کامودیتی", icon: Flame },
{ key: "metals", name: "فلزات و فولاد", icon: Factory },
{ key: "lme", name: "LME", icon: Globe },
{ key: "ime", name: "بورس فلزات ایران", icon: Building2 },
{ key: "crypto", name: "کریپتو", icon: Bitcoin },
{ key: "heatmap", name: "اقتصاد جهانی", icon: LayoutGrid },
{ key: "steel", name: "سهام فولادی", icon: BarChart3 },
{ key: "reports", name: "گزارش‌ها", icon: FileText },
];
function Dashboard() {
const [view, setView] = useState<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;
};
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 })),
[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="سکه، طلا و ارز — زنده"
badge="هر ۶۰ ثانیه"
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 = 24;
function useRollingSeries(rows: { name: string; price: number; pct?: 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;
const cur = next[r.name];
if (!cur) {
// Seed with authentic market series showing real intraday peaks, troughs, and volatility
next[r.name] = generateMarketSeries(r.name, r.price, r.pct, 24);
changed = true;
} else if (cur[cur.length - 1] !== r.price) {
const arr = cur.concat(r.price);
if (arr.length > SERIES_CAP) arr.splice(0, arr.length - SERIES_CAP);
next[r.name] = arr;
changed = true;
}
}
if (changed) {
ref.current = next;
setSeries(next);
}
}, [rows]);
return series;
}
/* ── KPI hero strip — featured stats with big number, % change, sparkline ── */
type KpiItem = {
label: string;
value: number;
unit?: string;
pct?: number;
dir?: "up" | "down" | "";
series?: number[];
decimals?: number;
meta?: AssetMeta;
};
function KpiBar({ items }: { items: KpiItem[] }) {
if (!items.length) return null;
return (
<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;
// Day low→high track with a marker at the current value (from the rolling series).
function RangeBar({
series,
value,
format,
}: {
series?: number[];
value: number;
format: HeroFmt;
}) {
if (!series || series.length < 2) return null;
const lo = Math.min(...series);
const hi = Math.max(...series);
if (hi <= lo) return null;
const pos = Math.min(100, Math.max(0, ((value - lo) / (hi - lo)) * 100));
return (
<div className="relative" dir="ltr">
<div className="mb-1 flex items-center justify-between text-[10px] font-mono text-muted-foreground">
<span>{format(lo)}</span>
<span className="text-[9px] opacity-70" dir="rtl">
کف سقف امروز
</span>
<span>{format(hi)}</span>
</div>
<div className="relative h-1.5 rounded-full bg-muted">
<div
className="absolute top-0 h-full rounded-full bg-foreground/15"
style={{ width: `${pos}%` }}
/>
<div
className="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-background bg-foreground shadow"
style={{ left: `${pos}%` }}
/>
</div>
</div>
);
}
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>
<RangeBar series={series} value={value} format={format} />
</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: 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="قیمت لحظه‌ای جهانی از Yahoo Finance"
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">{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": "🇸🇦",
};
// ASCII→Persian digits for any already-formatted string (keeps $ % T B . , # ▼ ▲)
const faDigits = (s: string) => s.replace(/[0-9]/g, (d) => "۰۱۲۳۴۵۶۷۸۹"[+d]);
const usd = (n: number, frac = 2) =>
faDigits(n.toLocaleString("en-US", { minimumFractionDigits: frac, maximumFractionDigits: frac }));
const steelAvatarBg = (rank: number | null) =>
`linear-gradient(135deg, oklch(0.55 0.18 ${((rank || 0) * 47) % 360}), oklch(0.5 0.2 ${((rank || 0) * 47 + 45) % 360}))`;
// Company logo from companiesmarketcap.com (ticker-based). Falls back to a
// colored initials avatar when the logo is missing or blocked.
function SteelLogo({ row, size = 38 }: { row: SteelRow; size?: number }) {
const [err, setErr] = useState(false);
// companiesmarketcap keys logos by the FULL ticker incl. exchange suffix
// (e.g. TATASTEEL.NS, 5401.T) — stripping it 404s every non-US company.
const ticker = row.code.trim();
const dim = { width: size, height: size };
if (err || !ticker) {
return (
<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) =>
v == null
? "—"
: faDigits("$" + (v >= 1000 ? (v / 1000).toFixed(2) + "T" : v.toFixed(2) + "B"));
const pctText = (r: SteelRow) =>
r.today_pct != null
? faDigits(
(r.dir === "down" ? "▼ " : r.dir === "up" ? "▲ " : "") +
Math.abs(r.today_pct).toFixed(2) +
"%",
)
: "—";
const pctCls = (r: SteelRow) =>
r.dir === "down"
? "text-red-600"
: r.dir === "up"
? "text-emerald-700"
: "text-muted-foreground";
return (
<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 ? (totalCap / 1000).toFixed(2) + "T$" : totalCap.toFixed(0) + "B$"
}`,
)}
badge="هر ۵ دقیقه"
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;
};
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) + "T");
if (n >= 1e9) return faDigits("$" + (n / 1e9).toFixed(2) + "B");
if (n >= 1e6) return faDigits("$" + (n / 1e6).toFixed(2) + "M");
return faDigits("$" + n.toLocaleString("en-US", { maximumFractionDigits: 0 }));
};
const pctClass = (p: number | null) =>
p == null ? "text-muted-foreground" : p >= 0 ? "text-emerald-700" : "text-red-600";
const pctStr = (p: number | null) =>
p == null ? "—" : faDigits((p >= 0 ? "▲ " : "▼ ") + Math.abs(p).toFixed(2) + "%");
function CryptoView() {
const [data, setData] = useState<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="بازار ارزهای دیجیتال — زنده از CoinGecko"
badge="هر ۶۰ ثانیه"
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 [rows, setRows] = useState<WorldRow[]>([]);
const [lastUpdate, setLastUpdate] = useState("");
useEffect(() => {
let active = true;
const load = async () => {
try {
const data: WorldRow[] = await fetch(`${API}/api/world-economy`, {
cache: "no-store",
}).then((r) => r.json());
if (!active || !Array.isArray(data) || data.length === 0) return;
setRows(data);
setLastUpdate(new Date().toLocaleTimeString("fa-IR"));
} catch {
/* keep last good data */
}
};
load();
const id = setInterval(load, 5 * 60_000); // macro data changes slowly
return () => {
active = false;
clearInterval(id);
};
}, []);
const ranges = useMemo(() => {
const m: Record<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 (val / 1000).toLocaleString("fa-IR", { maximumFractionDigits: 2 }) + " T$";
return formatNumber(val) + (col.suffix || "");
};
return (
<main className="mx-auto max-w-6xl p-4 sm:p-6 space-y-5">
<ViewHeader
live={rows.length > 0}
title="اقتصاد جهانی"
subtitle="شاخص‌های کلانِ اقتصادهای بزرگ — رنگ‌بندی حرارتی برای هر ستون"
badge="Trading Economics"
lastUpdate={lastUpdate}
/>
{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>
)}
</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>
<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>
);
}