Bilingual site (FA/EN), redesigned sections, new pages

- Full FA/EN toggle via LangContext across all pages and sections
- New pages: Radar, Technology, Pulse, Membership
- Home restructured: Hero → SnapshotBar → 5 chapters
- FactoryBentoSection: Mobarakeh Steel monthly reports (bento grid)
- SteelNewsletterSection: bilingual newsletter + factory list
- Section labels: Persian numerals, editorial rule style
- Header: LTR in EN mode, bilingual nav
- Footer: updated copyright and tagline
- Typography: consistent h2 sizes across sections
- BentoGrid: larger cards (p-8, 18px title)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alireza 2026-05-31 11:18:35 +03:30
parent 38956bf77a
commit 60bf240cf5
30 changed files with 2262 additions and 1054 deletions

27
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "andishkade-foolad",
"version": "0.0.0",
"dependencies": {
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.14",
"apexcharts": "^5.13.0",
"clsx": "^2.1.1",
@ -852,6 +853,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tabler/icons": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.44.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",

View File

@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@tabler/icons-react": "^3.44.0",
"@tanstack/react-query": "^5.100.14",
"apexcharts": "^5.13.0",
"clsx": "^2.1.1",

View File

@ -40,8 +40,83 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_articles_created ON articles(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_articles_featured ON articles(featured);
CREATE TABLE IF NOT EXISTS risk_signals (
id TEXT PRIMARY KEY,
quote TEXT NOT NULL,
name TEXT NOT NULL,
date TEXT,
level TEXT NOT NULL CHECK (level IN ('critical','high','medium','low','opportunity')),
sort_order INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_risks_sort ON risk_signals(sort_order, created_at);
CREATE TABLE IF NOT EXISTS prices (
symbol TEXT PRIMARY KEY,
name TEXT NOT NULL,
value REAL,
unit TEXT,
change_value REAL,
change_pct REAL,
source_url TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
export function rowToPrice(row) {
if (!row) return null;
return {
symbol: row.symbol,
name: row.name,
value: row.value,
unit: row.unit,
changeValue: row.change_value,
changePct: row.change_pct,
sourceUrl: row.source_url,
fetchedAt: row.fetched_at,
};
}
export function upsertPrice(p) {
db.prepare(`
INSERT INTO prices (symbol, name, value, unit, change_value, change_pct, source_url, fetched_at)
VALUES (@symbol, @name, @value, @unit, @change_value, @change_pct, @source_url, datetime('now'))
ON CONFLICT(symbol) DO UPDATE SET
name = excluded.name,
value = excluded.value,
unit = excluded.unit,
change_value = excluded.change_value,
change_pct = excluded.change_pct,
source_url = excluded.source_url,
fetched_at = excluded.fetched_at
`).run({
symbol: p.symbol,
name: p.name,
value: p.value ?? null,
unit: p.unit ?? null,
change_value: p.changeValue ?? null,
change_pct: p.changePct ?? null,
source_url: p.sourceUrl ?? null,
});
}
export function rowToRiskSignal(row) {
if (!row) return null;
return {
id: row.id,
quote: row.quote,
name: row.name,
date: row.date,
level: row.level,
sortOrder: row.sort_order,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export function rowToArticle(row) {
if (!row) return null;
return {

304
panel/package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
@ -141,6 +142,12 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@ -226,6 +233,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@ -306,6 +355,34 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -367,6 +444,61 @@
"node": ">=8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@ -417,6 +549,31 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@ -426,6 +583,18 @@
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -662,6 +831,37 @@
"node": ">= 0.4"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@ -1010,6 +1210,18 @@
"node": ">=10"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -1052,6 +1264,55 @@
"wrappy": "1"
}
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1527,6 +1788,15 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undici": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz",
"integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -1560,6 +1830,40 @@
"node": ">= 0.8"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",

View File

@ -13,6 +13,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.3.0",
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",

View File

@ -71,21 +71,40 @@ function renderLogin(errorMsg = '') {
});
}
async function renderList() {
root.innerHTML = `
function topbarHtml(active, primaryLabel) {
return `
<header class="topbar">
<div class="brand">پنل اندیشکده فولاد</div>
<nav class="tabs">
<button data-tab="articles" class="${active==='articles'?'tab-active':''}">مطالب</button>
<button data-tab="risks" class="${active==='risks'?'tab-active':''}">ریسکها</button>
</nav>
<div class="actions">
<button id="newBtn" class="primary">+ مطلب جدید</button>
<button id="newBtn" class="primary">${primaryLabel}</button>
<button id="logoutBtn" class="ghost">خروج</button>
</div>
</header>
`;
}
function wireTabs() {
document.querySelectorAll('.tabs button').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.tab === 'articles') renderList();
else if (btn.dataset.tab === 'risks') renderRisksList();
});
});
$('#logoutBtn').addEventListener('click', () => { clearToken(); renderLogin(); });
}
async function renderList() {
root.innerHTML = `
${topbarHtml('articles', '+ مطلب جدید')}
<main class="page">
<h2>مطالب</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>
`;
$('#logoutBtn').addEventListener('click', () => { clearToken(); renderLogin(); });
wireTabs();
$('#newBtn').addEventListener('click', () => renderEditor(null));
try {
@ -290,5 +309,130 @@ function escapeHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const RISK_LEVELS = [
{ value: 'critical', label: 'بحرانی' },
{ value: 'high', label: 'بالا' },
{ value: 'medium', label: 'متوسط' },
{ value: 'low', label: 'پایین' },
{ value: 'opportunity', label: 'فرصت' },
];
async function renderRisksList() {
root.innerHTML = `
${topbarHtml('risks', '+ سیگنال جدید')}
<main class="page">
<h2>سیگنالهای ریسک</h2>
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderRiskEditor(null));
try {
const items = await api('/api/risks?limit=200');
if (!items.length) {
$('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده است.</p>`;
return;
}
const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || lvl;
$('#list').innerHTML = items.map(r => `
<article class="row risk-row" data-id="${r.id}">
<div class="row-main">
<div class="row-meta">
<span class="pill level-${r.level}">${labelOf(r.level)}</span>
<strong>${r.name}</strong>
${r.date ? `<span class="muted">${r.date}</span>` : ''}
</div>
<p class="muted clamp-2">${r.quote}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (btn.dataset.action === 'edit') {
const r = await api(`/api/risks/${id}`);
renderRiskEditor(r);
} else if (btn.dataset.action === 'delete') {
if (!confirm('این سیگنال حذف شود؟')) return;
await api(`/api/risks/${id}`, { method: 'DELETE' });
renderRisksList();
}
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری سیگنال‌ها.</p>`;
}
}
function renderRiskEditor(risk) {
const r = risk || { quote: '', name: '', date: '', level: 'high', sortOrder: 0 };
const isEdit = !!risk;
root.innerHTML = `
<header class="topbar">
<div class="brand">${isEdit ? 'ویرایش سیگنال ریسک' : 'سیگنال ریسک جدید'}</div>
<div class="actions">
<button id="backBtn" class="ghost"> بازگشت</button>
</div>
</header>
<main class="page editor">
<form id="editorForm" class="grid">
<div class="col span-2">
<label>عنوان / منبع
<input name="name" value="${escapeAttr(r.name)}" required />
</label>
</div>
<label>سطح
<select name="level">
${RISK_LEVELS.map(l => `<option value="${l.value}" ${l.value===r.level?'selected':''}>${l.label}</option>`).join('')}
</select>
</label>
<label>تاریخ / افق (شمسی)
<input name="date" value="${escapeAttr(r.date)}" />
</label>
<label>ترتیب نمایش (عدد کوچکتر اول)
<input name="sortOrder" type="number" value="${r.sortOrder || 0}" />
</label>
<div class="col span-2">
<label>متن سیگنال
<textarea name="quote" rows="6" required>${escapeHtml(r.quote)}</textarea>
</label>
</div>
<div class="col span-2 row-end">
<button type="submit" class="primary">${isEdit ? 'ذخیره تغییرات' : 'ایجاد'}</button>
</div>
</form>
</main>
`;
$('#backBtn').addEventListener('click', () => renderRisksList());
$('#editorForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = {
name: fd.get('name'),
quote: fd.get('quote'),
date: fd.get('date') || null,
level: fd.get('level'),
sortOrder: Number(fd.get('sortOrder') || 0),
};
try {
if (isEdit) await api(`/api/risks/${risk.id}`, { method: 'PUT', body: payload });
else await api('/api/risks', { method: 'POST', body: payload });
renderRisksList();
} catch (err) {
alert('ذخیره ناموفق بود: ' + err.message);
}
});
}
if (getToken()) renderList();
else renderLogin();

View File

@ -114,6 +114,40 @@ textarea { resize: vertical; min-height: 80px; }
.topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; }
.topbar .actions { display: flex; gap: 10px; }
.tabs {
display: flex;
gap: 4px;
flex: 1;
justify-content: center;
}
.tabs button {
border: none;
background: transparent;
color: var(--ink-3);
font-size: 13px;
font-weight: 700;
padding: 8px 18px;
border-bottom: 2px solid transparent;
border-radius: 0;
}
.tabs button:hover {
background: transparent;
color: var(--ink);
}
.tabs button.tab-active {
color: var(--ink);
border-bottom-color: var(--red);
}
.risk-row { grid-template-columns: 1fr auto; }
.risk-row .row-meta strong { font-size: 13px; }
.pill.level-critical { background: #7f1d1d; color: white; }
.pill.level-high { background: #b91c1c; color: white; }
.pill.level-medium { background: #92400e; color: white; }
.pill.level-low { background: #166534; color: white; }
.pill.level-opportunity { background: #1e40af; color: white; }
.page { max-width: 1100px; margin: 0 auto; padding: 32px; }
.page h2 { font-size: 22px; letter-spacing: -.5px; margin: 0 0 24px; }

156
panel/scraper.js Normal file
View File

@ -0,0 +1,156 @@
import * as cheerio from 'cheerio';
import { db, upsertPrice } from './db.js';
const SOURCES = [
{ url: 'https://www.tgju.org/basemetal', kind: 'basemetal', defaultUnit: 'دلار/تن' },
{ url: 'https://www.tgju.org/gold-global', kind: 'gold', defaultUnit: 'دلار/اونس' },
];
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/125.0 Safari/537.36';
async function fetchPage(url, timeoutMs = 20000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: {
'User-Agent': USER_AGENT,
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'fa,en;q=0.8',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return await res.text();
} finally {
clearTimeout(t);
}
}
// Convert Persian/Arabic digits to ASCII, drop thousand separators, keep sign/decimal
function toNumber(raw) {
if (raw == null) return null;
const ascii = String(raw)
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
.replace(/[,،\s]/g, '')
.replace(/[()٪%]/g, '')
.replace(/[^\d.+\-]/g, '');
if (!ascii || ascii === '+' || ascii === '-' || ascii === '.') return null;
const n = parseFloat(ascii);
return Number.isFinite(n) ? n : null;
}
// Parse the TGJU change cell — known formats:
// "(0.26%) 12.38" → pct=0.26, value=12.38
// "(-0.5%) -55" → pct=-0.5, value=-55
// "0" or "" → pct=0, value=0
function parseChangeCell(raw) {
if (!raw) return { changePct: null, changeValue: null };
const text = raw.trim();
if (!text || text === '0' || text === '۰' || text === '٠') {
return { changePct: 0, changeValue: 0 };
}
let changePct = null;
let changeValue = null;
const pctMatch = text.match(/\(\s*(-?[\d.,۰-۹٠-٩]+)\s*%\s*\)/);
if (pctMatch) changePct = toNumber(pctMatch[1]);
// Remove the parenthesised pct, what's left should be the absolute change
const withoutPct = text.replace(/\([^)]*\)/g, '').trim();
if (withoutPct) changeValue = toNumber(withoutPct);
return { changePct, changeValue };
}
function parsePrices(html, sourceUrl, defaultUnit) {
const $ = cheerio.load(html);
const byName = new Map();
$('tr[data-market-row], tr[data-market-nameslug]').each((idx, tr) => {
const $tr = $(tr);
const symbolAttr = $tr.attr('data-market-row') || $tr.attr('data-market-nameslug') || `row-${idx}`;
// Name lives in <th> for these tables (not the first <td>).
let name = $tr.find('th').first().text().trim();
if (!name) name = $tr.find('a').first().text().trim();
name = name.replace(/\s+/g, ' ').trim();
if (!name || /^(عنوان|بازار|نام)$/.test(name)) return;
if (name.length > 60) return;
// Price: prefer the row-level data-price attr (always clean numeric),
// else first <td>'s text.
const dataPrice = $tr.attr('data-price');
let value = toNumber(dataPrice);
if (value === null) {
const firstTd = $tr.find('td').first().text().trim();
value = toNumber(firstTd);
}
if (!Number.isFinite(value) || value === 0) return;
// Change: TGJU puts the entire change expression in the SECOND <td>
// ("(0.26%) 12.38" format). Earlier rows of the page may not have it,
// so we tolerate missing data.
const changeCellRaw = $tr.find('td').eq(1).text().trim();
const { changePct, changeValue } = parseChangeCell(changeCellRaw);
// Dedup by visible name; prefer non-generic symbols.
const existing = byName.get(name);
const isGeneric = /^general[_-]?\d+$/i.test(symbolAttr);
if (existing) {
const existingIsGeneric = /^general[_-]?\d+$/i.test(existing.symbol);
if (!existingIsGeneric || isGeneric) return;
}
byName.set(name, {
symbol: symbolAttr,
name,
value,
unit: defaultUnit,
changeValue,
changePct,
sourceUrl,
});
});
return Array.from(byName.values());
}
export async function scrapeOnce() {
// Collect results before mutating DB — if ALL sources fail, keep old data.
const successful = [];
for (const src of SOURCES) {
try {
const html = await fetchPage(src.url);
const items = parsePrices(html, src.url, src.defaultUnit);
successful.push({ src, items });
console.log(`[scraper] ${src.kind}: ${items.length} prices parsed`);
} catch (err) {
console.error(`[scraper] ${src.kind} failed: ${err.message}`);
}
}
if (successful.length === 0) {
console.log('[scraper] all sources failed — keeping previous data');
return 0;
}
// At least one source returned — replace the rows from those sources only.
// This way a temporary failure of one page doesn't wipe the other's data.
for (const { src, items } of successful) {
db.prepare('DELETE FROM prices WHERE source_url = ?').run(src.url);
for (const it of items) upsertPrice(it);
}
return successful.reduce((s, x) => s + x.items.length, 0);
}
let interval = null;
export function startScraperLoop(intervalMs = 2 * 60 * 1000) {
if (interval) return;
setTimeout(() => { scrapeOnce().catch((e) => console.error('[scraper] initial:', e)); }, 1500);
interval = setInterval(() => {
scrapeOnce().catch((e) => console.error('[scraper] tick:', e));
}, intervalMs);
}
export function stopScraperLoop() {
if (interval) { clearInterval(interval); interval = null; }
}

View File

@ -8,7 +8,8 @@ import { nanoid } from 'nanoid';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { db, rowToArticle } from './db.js';
import { db, rowToArticle, rowToRiskSignal, rowToPrice } from './db.js';
import { startScraperLoop, scrapeOnce } from './scraper.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 3001);
@ -158,8 +159,94 @@ app.delete('/api/articles/:id', authRequired, (req, res) => {
res.status(204).end();
});
// ---------- risk signals ----------
const RISK_FIELDS = `id, quote, name, date, level, sort_order, created_at, updated_at`;
app.get('/api/risks', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const rows = db
.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals ORDER BY sort_order ASC, created_at DESC LIMIT ?`)
.all(limit);
res.json(rows.map(rowToRiskSignal));
});
app.get('/api/risks/:id', (req, res) => {
const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
res.json(rowToRiskSignal(row));
});
const ALLOWED_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']);
function normalizeRiskBody(b) {
const level = ALLOWED_LEVELS.has(b.level) ? b.level : null;
return {
quote: String(b.quote || '').trim(),
name: String(b.name || '').trim(),
date: b.date || null,
level,
sort_order: Number(b.sortOrder) || 0,
};
}
app.post('/api/risks', authRequired, (req, res) => {
const r = normalizeRiskBody(req.body || {});
if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' });
const id = nanoid(14);
db.prepare(`
INSERT INTO risk_signals (id, quote, name, date, level, sort_order)
VALUES (@id, @quote, @name, @date, @level, @sort_order)
`).run({ id, ...r });
const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(id);
res.status(201).json(rowToRiskSignal(row));
});
app.put('/api/risks/:id', authRequired, (req, res) => {
const existing = db.prepare('SELECT id FROM risk_signals WHERE id = ?').get(req.params.id);
if (!existing) return res.status(404).json({ error: 'not_found' });
const r = normalizeRiskBody(req.body || {});
if (!r.quote || !r.name || !r.level) return res.status(400).json({ error: 'quote_name_level_required' });
db.prepare(`
UPDATE risk_signals SET
quote = @quote, name = @name, date = @date, level = @level,
sort_order = @sort_order, updated_at = datetime('now')
WHERE id = @id
`).run({ id: req.params.id, ...r });
const row = db.prepare(`SELECT ${RISK_FIELDS} FROM risk_signals WHERE id = ?`).get(req.params.id);
res.json(rowToRiskSignal(row));
});
app.delete('/api/risks/:id', authRequired, (req, res) => {
const info = db.prepare('DELETE FROM risk_signals WHERE id = ?').run(req.params.id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// ---------- prices (scraped from tgju.org every 2 min) ----------
app.get('/api/prices', (_req, res) => {
const rows = db
.prepare('SELECT symbol, name, value, unit, change_value, change_pct, source_url, fetched_at FROM prices ORDER BY name')
.all();
res.json(rows.map(rowToPrice));
});
app.post('/api/prices/refresh', authRequired, async (_req, res) => {
try {
const n = await scrapeOnce();
res.json({ ok: true, scraped: n });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
app.get('/api/health', (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`Panel running at ${PUBLIC_ORIGIN}`);
const intervalMs = Number(process.env.SCRAPER_INTERVAL_MS) || 2 * 60 * 1000;
if (process.env.SCRAPER_DISABLED !== '1') {
startScraperLoop(intervalMs);
console.log(`[scraper] tgju.org polling every ${intervalMs / 1000}s`);
} else {
console.log('[scraper] disabled via SCRAPER_DISABLED=1');
}
});

View File

@ -28,7 +28,6 @@ function SocialIcon({ label }: { label: string }) {
function FooterLink({ href, label }: { href: string; label: string }) {
const [hovered, setHovered] = useState(false)
return (
<li style={{ marginBottom: 10 }}>
<a
href={href}
onMouseEnter={() => setHovered(true)}
@ -41,45 +40,19 @@ function FooterLink({ href, label }: { href: string; label: string }) {
transition: 'color 150ms',
display: 'inline-block',
lineHeight: 1.4,
whiteSpace: 'nowrap',
}}
>
{label}
</a>
</li>
)
}
const COLUMNS = [
{
heading: 'گزارش‌ها',
links: [
{ label: 'گزارش‌های فصلی', href: '#' },
{ label: 'Flash Reports', href: '#' },
{ label: 'گزارش‌های ریسک', href: '#' },
{ label: 'گزارش رایگان', href: '#' },
{ label: 'آرشیو کامل', href: '#' },
],
},
{
heading: 'اندیشکده',
links: [
const FOOTER_LINKS = [
{ label: 'توضیح اندیشکده', href: '#' },
{ label: 'درباره ما', href: '#' },
{ label: 'تیم تحریریه', href: '#' },
{ label: 'اشتراک سازمانی', href: '#' },
{ label: 'همکاری با ما', href: '#' },
{ label: 'تماس', href: '#' },
],
},
{
heading: 'منابع',
links: [
{ label: 'داشبورد بازار', href: '#' },
{ label: 'رویدادها', href: '#' },
{ label: 'اسکنر جهانی', href: '#' },
{ label: 'خبرنامه', href: '#' },
{ label: 'درگاه پرداخت', href: '#' },
],
},
{ label: 'تماس با ما', href: '#' },
]
export function Footer() {
@ -88,44 +61,22 @@ export function Footer() {
dir="rtl"
style={{ backgroundColor: 'var(--ink)', color: 'rgba(255,255,255,0.6)' }}
>
{/* TOP GRID */}
<div style={{ padding: '64px 0' }} className="max-w-7xl mx-auto w-full px-12 max-md:px-5">
<div
style={{
display: 'grid',
gridTemplateColumns: '2fr 1px 1fr 1px 1fr 1px 1fr',
gap: 0,
}}
className="mq-stack max-md:grid-cols-1 max-md:gap-10"
>
{/* Brand */}
<div style={{ paddingLeft: 0, paddingRight: 48 }} className="max-md:pr-0">
<div
style={{
fontSize: 22, fontWeight: 900, color: '#fff',
lineHeight: 1.2, marginBottom: 4,
}}
>
{/* TOP SECTION */}
<div style={{ padding: '56px 0 48px' }} className="max-w-7xl mx-auto w-full px-12 max-md:px-5">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 40 }}>
{/* Brand + description */}
<div style={{ maxWidth: 360 }}>
<div style={{ fontSize: 22, fontWeight: 900, color: '#fff', lineHeight: 1.2, marginBottom: 4 }}>
اندیشکده فولاد آینده
</div>
<div
style={{
fontSize: 10, letterSpacing: 3,
color: 'rgba(255,255,255,0.3)', textTransform: 'uppercase',
marginBottom: 20, direction: 'ltr', textAlign: 'right',
}}
>
<div style={{ fontSize: 10, letterSpacing: 3, color: 'rgba(255,255,255,0.3)', textTransform: 'uppercase', marginBottom: 20, direction: 'ltr', textAlign: 'right' }}>
FUTURE STEEL POLICY INSTITUTE
</div>
<p style={{ fontSize: 13, lineHeight: 1.75, color: 'rgba(255,255,255,0.5)', marginBottom: 20, maxWidth: 300 }}>
مرکز مستقل تحلیل راهبردی و سیاستگذاری صنعت فولاد و فلزات کشور
<p style={{ fontSize: 13, lineHeight: 1.8, color: 'rgba(255,255,255,0.5)', marginBottom: 24 }}>
مرکز مستقل تحلیل راهبردی و سیاستگذاری صنعت فولاد و فلزات کشور زیر نظر شرکت فولاد مبارکه
</p>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.4)', lineHeight: 2, marginBottom: 20 }}>
<div>تهران · خیابان ولیعصر</div>
<div>info@iransteel.ir</div>
<div dir="ltr" style={{ textAlign: 'right' }}>۰۲۱۸۸۱۲۳۴۵۶</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 6 }}>
<SocialIcon label="in" />
<SocialIcon label="tg" />
<SocialIcon label="tw" />
@ -133,67 +84,24 @@ export function Footer() {
</div>
</div>
{/* Dividers + link columns */}
{COLUMNS.map((col, i) => (
<>
<div
key={`div-${i}`}
aria-hidden="true"
style={{ background: 'rgba(255,255,255,0.08)', width: 1 }}
className="max-md:hidden"
/>
<div key={col.heading} style={{ padding: '0 36px' }} className="max-md:p-0">
<h5
style={{
fontSize: 9, fontWeight: 700, letterSpacing: 3,
textTransform: 'uppercase', color: 'rgba(255,255,255,0.35)',
margin: '0 0 16px', paddingBottom: 10,
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}
>
{col.heading}
</h5>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{col.links.map(link => (
{/* 4 links */}
<nav aria-label="پیوندهای پایین" style={{ display: 'flex', gap: 32, flexWrap: 'wrap', alignItems: 'center' }}>
{FOOTER_LINKS.map(link => (
<FooterLink key={link.label} href={link.href} label={link.label} />
))}
</ul>
</div>
</>
))}
</nav>
</div>
</div>
{/* BOTTOM ROW */}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<div
style={{
borderTop: '1px solid rgba(255,255,255,0.08)',
}}
>
<div
style={{
padding: '20px 48px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 10,
color: 'rgba(255,255,255,0.25)',
flexWrap: 'wrap',
gap: 12,
}}
style={{ padding: '22px 0', fontSize: 13, color: 'rgba(255,255,255,0.85)', lineHeight: 1.8, textAlign: 'center', fontWeight: 500 }}
className="max-w-7xl mx-auto w-full px-12 max-md:px-5"
>
<span>© ۱۴۰۳ اندیشکده فولاد آینده تمام حقوق محفوظ است</span>
<div style={{ display: 'flex', gap: 18 }}>
{['حریم خصوصی', 'شرایط استفاده', 'درگاه پرداخت', 'نقشه سایت'].map(l => (
<a
key={l}
href="#"
style={{ color: 'rgba(255,255,255,0.25)', textDecoration: 'none' }}
>
{l}
</a>
))}
<div>تمامی حقوق این وب سایت محفوظ و متعلق به شرکت فولاد مبارکه است.</div>
<div style={{ color: 'rgba(255,255,255,0.55)', marginTop: 6, fontSize: 12 }}>
با افتخار قدرت گرفته از دیدوان؛ چشم همیشه باز مدیران
</div>
</div>
</div>

View File

@ -3,6 +3,7 @@ import type { CSSProperties } from 'react'
import { NavLink } from 'react-router-dom'
import { Search, Menu, X } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useLang } from '@/context/LangContext'
/*
DESIGN TOKENS
@ -21,17 +22,21 @@ const T = {
/*
DATA
*/
const NAV_ITEMS = [
{ label: 'گزارش‌ها', to: '/reports' },
{ label: 'گزارش ویژه', to: '/special' },
{ label: 'ریسک‌ها', to: '/risks' },
{ label: 'رویدادها', to: '/events' },
{ label: 'اسکنر جهانی', to: '/scanner' },
{ label: 'تیم تحریریه', to: '/team' },
{ label: 'آرشیو', to: '/archive' },
const NAV_ITEMS: { fa: string; en: string; to: string }[] = [
{ fa: 'خانه', en: 'Home', to: '/' },
{ fa: 'رادار آینده', en: 'Future Radar', to: '/radar' },
{ fa: 'مرز فناوری', en: 'Tech Frontier', to: '/technology' },
{ fa: 'نبض صنعت', en: 'Industry Pulse', to: '/pulse' },
{ fa: 'رویدادها', en: 'Events', to: '/events' },
{ fa: 'عضویت', en: 'Membership', to: '/membership' },
]
const META_LEFT = ['درباره ما', 'کارشناسان', 'همکاری', 'رسانه']
const META_LEFT: { fa: string; en: string }[] = [
{ fa: 'درباره ما', en: 'About' },
{ fa: 'کارشناسان', en: 'Experts' },
{ fa: 'همکاری', en: 'Careers' },
{ fa: 'رسانه', en: 'Press' },
]
/*
NAV LINK (used in main + drawer)
@ -103,6 +108,7 @@ export default function Header() {
const [searchOpen, setSearchOpen] = useState(false)
const [scrolled, setScrolled] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const { lang, toggle: toggleLang } = useLang()
/* scroll listener — toggles glass + collapses meta strip */
useEffect(() => {
@ -142,7 +148,7 @@ export default function Header() {
backdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
WebkitBackdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
borderBottom: scrolled ? '1px solid rgba(26,23,18,0.08)' : '1px solid transparent',
direction: 'rtl',
direction: lang === 'fa' ? 'rtl' : 'ltr',
transition: 'background 220ms ease, backdrop-filter 220ms ease, border-color 220ms ease',
}}
>
@ -172,14 +178,14 @@ export default function Header() {
>
{/* Right: date */}
<span style={{ letterSpacing: '0.3px' }}>
شنبه ۲۱ بهمن ۱۴۰۳ · دوره دوازدهم
{lang === 'fa' ? 'شنبه ۲۱ بهمن ۱۴۰۳ · دوره دوازدهم' : 'Sat 10 Feb 2025 · Vol. XII'}
</span>
{/* Left: meta links + login */}
{/* Left: meta links + login + lang toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 18 }}>
{META_LEFT.map((item) => (
<a
key={item}
key={item.fa}
href="#"
style={{
fontSize: 11,
@ -190,7 +196,7 @@ export default function Header() {
onMouseEnter={e => (e.currentTarget.style.color = 'var(--paper)')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(244,239,231,0.7)')}
>
{item}
{lang === 'fa' ? item.fa : item.en}
</a>
))}
<span style={{ color: 'rgba(244,239,231,0.2)' }}>·</span>
@ -203,18 +209,26 @@ export default function Header() {
fontWeight: 600,
}}
>
ورود
{lang === 'fa' ? 'ورود' : 'Login'}
</a>
<a
href="#"
<button
onClick={toggleLang}
style={{
fontSize: 11,
color: 'rgba(244,239,231,0.7)',
textDecoration: 'none',
background: 'transparent',
border: '1px solid rgba(244,239,231,0.2)',
padding: '2px 8px',
cursor: 'pointer',
fontFamily: 'inherit',
letterSpacing: '0.5px',
transition: 'color 120ms, border-color 120ms',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--paper)'; e.currentTarget.style.borderColor = 'rgba(244,239,231,0.5)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'rgba(244,239,231,0.7)'; e.currentTarget.style.borderColor = 'rgba(244,239,231,0.2)' }}
>
EN
</a>
{lang === 'fa' ? 'EN' : 'FA'}
</button>
</div>
</div>
</div>
@ -290,7 +304,7 @@ export default function Header() {
}}
>
{NAV_ITEMS.map((item) => (
<MainNavItem key={item.to} to={item.to} label={item.label} />
<MainNavItem key={item.to} to={item.to} label={lang === 'fa' ? item.fa : item.en} />
))}
</nav>
)}
@ -467,7 +481,7 @@ export default function Header() {
<DrawerNavItem
key={item.to}
to={item.to}
label={item.label}
label={lang === 'fa' ? item.fa : item.en}
onClose={() => setMobileOpen(false)}
/>
))}
@ -484,10 +498,10 @@ export default function Header() {
}}
>
<RedCtaButton style={{ width: '100%', padding: '10px 16px' }}>
اشتراک سازمانی
{lang === 'fa' ? 'اشتراک سازمانی' : 'Membership'}
</RedCtaButton>
<OutlineButton style={{ width: '100%', padding: '10px 16px' }}>
ورود
{lang === 'fa' ? 'ورود' : 'Login'}
</OutlineButton>
<div
style={{
@ -499,11 +513,11 @@ export default function Header() {
>
{META_LEFT.map((item) => (
<a
key={item}
key={item.fa}
href="#"
style={{ fontSize: 11, color: T.ink4, textDecoration: 'none' }}
>
{item}
{lang === 'fa' ? item.fa : item.en}
</a>
))}
</div>

View File

@ -1,5 +1,45 @@
import { marketPrices } from '@/data/market';
import type { MarketPrice } from '@/data/market';
import { useEffect, useState } from 'react';
import { marketPrices as fallbackPrices } from '@/data/market';
import type { MarketPrice, PriceTrend } from '@/data/market';
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env
?.VITE_PANEL_API || 'http://localhost:3001';
type ApiPrice = {
symbol: string;
name: string;
value: number | null;
unit: string | null;
changeValue: number | null;
changePct: number | null;
sourceUrl: string | null;
};
function defaultUnitFor(sourceUrl: string | null): string {
if (!sourceUrl) return '';
if (sourceUrl.includes('gold-global')) return 'دلار/اونس';
if (sourceUrl.includes('basemetal')) return 'دلار/تن';
return '';
}
function apiToMarketPrices(items: ApiPrice[]): MarketPrice[] {
return items
.filter((p) => typeof p.value === 'number' && p.value > 0)
.map((p): MarketPrice => {
const pct = p.changePct ?? 0;
const trend: PriceTrend = pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat';
return {
id: p.symbol,
name: p.name,
value: p.value as number,
unit: p.unit || defaultUnitFor(p.sourceUrl),
change: p.changeValue ?? 0,
changePercent: pct,
trend,
};
});
}
function formatValue(item: MarketPrice): string {
return item.value.toLocaleString('fa-IR');
@ -70,6 +110,25 @@ function TickerItem({ item }: { item: MarketPrice }) {
}
export function MarketStrip() {
const [marketPrices, setMarketPrices] = useState<MarketPrice[]>(fallbackPrices);
useEffect(() => {
let cancelled = false;
const load = () => {
fetch(`${PANEL_API}/api/prices`)
.then((r) => (r.ok ? r.json() : null))
.then((data: ApiPrice[] | null) => {
if (cancelled || !Array.isArray(data) || data.length === 0) return;
const mapped = apiToMarketPrices(data);
if (mapped.length > 0) setMarketPrices(mapped);
})
.catch(() => { /* keep fallback */ });
};
load();
const id = setInterval(load, 2 * 60 * 1000);
return () => { cancelled = true; clearInterval(id); };
}, []);
return (
<div
dir="ltr"

View File

@ -2,9 +2,11 @@ import { Outlet } from 'react-router-dom'
import Header from './Header'
import { Footer } from './Footer'
import { MarketStrip } from './MarketStrip'
import { LangProvider } from '@/context/LangContext'
export default function RootLayout() {
return (
<LangProvider>
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column' }}>
<Header />
<MarketStrip />
@ -13,5 +15,6 @@ export default function RootLayout() {
</main>
<Footer />
</div>
</LangProvider>
)
}

View File

@ -11,6 +11,10 @@ import Archive from '@/pages/Archive/Archive'
import Special from '@/pages/Special/Special'
import Risks from '@/pages/Risks/Risks'
import Scanner from '@/pages/Scanner/Scanner'
import Radar from '@/pages/Radar/Radar'
import Technology from '@/pages/Technology/Technology'
import Pulse from '@/pages/Pulse/Pulse'
import Membership from '@/pages/Membership/Membership'
export const router = createBrowserRouter([
{
@ -28,6 +32,10 @@ export const router = createBrowserRouter([
{ path: 'special', element: <Special /> },
{ path: 'risks', element: <Risks /> },
{ path: 'scanner', element: <Scanner /> },
{ path: 'radar', element: <Radar /> },
{ path: 'technology', element: <Technology /> },
{ path: 'pulse', element: <Pulse /> },
{ path: 'membership', element: <Membership /> },
],
},
])

View File

@ -57,7 +57,7 @@ export function BentoGridItem({
<div
onClick={onClick}
className={cn(
'group relative flex flex-col bg-[var(--paper)] p-6 transition-colors duration-200 hover:bg-[var(--paper-2)]',
'group relative flex flex-col bg-[var(--paper)] p-8 transition-colors duration-200 hover:bg-[var(--paper-2)]',
onClick && 'cursor-pointer',
className,
)}
@ -102,9 +102,9 @@ export function BentoGridItem({
<h3
style={{
fontSize: 15,
fontSize: 18,
fontWeight: 700,
letterSpacing: '-0.3px',
letterSpacing: '-0.4px',
lineHeight: 1.4,
color: 'var(--ink)',
}}
@ -115,11 +115,11 @@ export function BentoGridItem({
{description && (
<p
style={{
fontSize: 13,
lineHeight: 1.75,
fontSize: 14,
lineHeight: 1.8,
color: 'var(--ink-3)',
fontWeight: 300,
marginTop: 2,
fontWeight: 400,
marginTop: 6,
}}
>
{description}

View File

@ -0,0 +1,17 @@
import { createContext, useContext, useState } from 'react'
import type { ReactNode } from 'react'
type Lang = 'fa' | 'en'
type LangCtx = { lang: Lang; toggle: () => void }
const LangContext = createContext<LangCtx>({ lang: 'fa', toggle: () => {} })
export function LangProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Lang>('fa')
const toggle = () => setLang(l => (l === 'fa' ? 'en' : 'fa'))
return <LangContext.Provider value={{ lang, toggle }}>{children}</LangContext.Provider>
}
export function useLang() {
return useContext(LangContext)
}

View File

@ -1,24 +1,9 @@
import { useState } from 'react'
import { events } from '@/data/events'
import type { EventType } from '@/data/events'
import { useLang } from '@/context/LangContext'
/* ─── constants ──────────────────────────────────────────── */
type FilterValue = 'همه' | EventType
const FILTERS: { label: string; value: FilterValue }[] = [
{ label: 'همه', value: 'همه' },
{ label: 'کنگره', value: 'conference' },
{ label: 'نمایشگاه', value: 'exhibition' },
{ label: 'سمینار', value: 'seminar' },
{ label: 'جهانی', value: 'international' },
]
const TYPE_LABELS: Record<EventType, string> = {
conference: 'کنگره',
exhibition: 'نمایشگاه',
seminar: 'سمینار',
international: 'جهانی',
}
type FilterValue = 'all' | EventType
const TYPE_BORDER_COLORS: Record<EventType, string> = {
conference: 'var(--ink)',
@ -27,174 +12,77 @@ const TYPE_BORDER_COLORS: Record<EventType, string> = {
international: 'var(--red)',
}
/* ─── event card ─────────────────────────────────────────── */
function EventCard({ event }: { event: (typeof events)[0] }) {
const TYPE_LABELS = {
fa: { conference: 'کنگره', exhibition: 'نمایشگاه', seminar: 'سمینار', international: 'جهانی' },
en: { conference: 'Conference', exhibition: 'Exhibition', seminar: 'Seminar', international: 'International' },
}
const FILTERS = {
fa: [
{ label: 'همه', value: 'all' as FilterValue },
{ label: 'کنگره', value: 'conference' as FilterValue },
{ label: 'نمایشگاه', value: 'exhibition' as FilterValue },
{ label: 'سمینار', value: 'seminar' as FilterValue },
{ label: 'جهانی', value: 'international' as FilterValue },
],
en: [
{ label: 'All', value: 'all' as FilterValue },
{ label: 'Conference', value: 'conference' as FilterValue },
{ label: 'Exhibition', value: 'exhibition' as FilterValue },
{ label: 'Seminar', value: 'seminar' as FilterValue },
{ label: 'International', value: 'international' as FilterValue },
],
}
const T = {
fa: { heading: 'رویدادهای پیش‌رو', year: '۱۴۰۳–۱۴۰۴', empty: 'رویدادی در این دسته‌بندی یافت نشد' },
en: { heading: 'Upcoming Events', year: '14031404', empty: 'No events found in this category' },
}
function EventCard({ event, lang }: { event: (typeof events)[0]; lang: 'fa' | 'en' }) {
const [hovered, setHovered] = useState(false)
const borderColor = TYPE_BORDER_COLORS[event.type]
const typeLabels = TYPE_LABELS[lang]
return (
<article
style={{
border: '1px solid var(--rule-thin)',
background: hovered ? 'var(--paper-2)' : 'var(--paper)',
transition: 'background 140ms',
display: 'flex',
flexDirection: 'column',
gap: 0,
}}
style={{ border: '1px solid var(--rule-thin)', background: hovered ? 'var(--paper-2)' : 'var(--paper)', transition: 'background 140ms', display: 'flex', flexDirection: 'column' }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* date + type header */}
<div style={{ display: 'flex', borderBottom: '2px solid var(--ink)' }}>
{/* date block */}
<div
style={{
padding: '18px 24px',
borderLeft: '1px solid var(--rule-thin)',
minWidth: 100,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--paper)',
flexShrink: 0,
}}
>
<span
style={{
fontSize: 52,
fontWeight: 900,
lineHeight: 1,
color: 'var(--ink)',
letterSpacing: '-2px',
display: 'block',
}}
>
{event.date.toLocaleString('fa-IR')}
<div style={{ padding: '18px 24px', borderLeft: '1px solid var(--rule-thin)', minWidth: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'var(--paper)', flexShrink: 0 }}>
<span style={{ fontSize: 52, fontWeight: 900, lineHeight: 1, color: 'var(--ink)', letterSpacing: '-2px', display: 'block' }}>
{event.date.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')}
</span>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: 'var(--ink-4)',
marginTop: 4,
display: 'block',
whiteSpace: 'nowrap',
}}
>
{event.month} {event.year.toLocaleString('fa-IR')}
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--ink-4)', marginTop: 4, display: 'block', whiteSpace: 'nowrap' }}>
{event.month} {event.year.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')}
</span>
</div>
{/* type badge + title area top */}
<div
style={{
flex: 1,
padding: '18px 20px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 8,
}}
>
<span
style={{
display: 'inline-block',
alignSelf: 'flex-start',
fontSize: 10,
fontWeight: 700,
letterSpacing: '1.2px',
color: borderColor,
border: `1px solid ${borderColor}`,
padding: '2px 8px',
}}
>
{TYPE_LABELS[event.type]}
<div style={{ flex: 1, padding: '18px 20px', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 8 }}>
<span style={{ display: 'inline-block', alignSelf: 'flex-start', fontSize: 10, fontWeight: 700, letterSpacing: '1.2px', color: borderColor, border: `1px solid ${borderColor}`, padding: '2px 8px' }}>
{typeLabels[event.type]}
</span>
<h3
style={{
fontSize: 16,
fontWeight: 800,
lineHeight: 1.45,
color: 'var(--ink)',
letterSpacing: '-0.3px',
}}
>
{event.title}
</h3>
<h3 style={{ fontSize: 16, fontWeight: 800, lineHeight: 1.45, color: 'var(--ink)', letterSpacing: '-0.3px' }}>{event.title}</h3>
</div>
</div>
{/* body */}
<div style={{ padding: '16px 20px', flex: 1, display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* location */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
width: 4,
height: 4,
background: 'var(--ink-4)',
flexShrink: 0,
display: 'inline-block',
}}
/>
<span style={{ fontSize: 12, color: 'var(--ink-4)', fontWeight: 400 }}>
{event.location}،&nbsp;{event.city}
</span>
<span style={{ width: 4, height: 4, background: 'var(--ink-4)', flexShrink: 0, display: 'inline-block' }} />
<span style={{ fontSize: 12, color: 'var(--ink-4)', fontWeight: 400 }}>{event.location}،&nbsp;{event.city}</span>
</div>
<p style={{ fontSize: 13, fontWeight: 300, color: 'var(--ink-3)', lineHeight: 1.8 }}>{event.description}</p>
</div>
{/* description */}
<p
style={{
fontSize: 13,
fontWeight: 300,
color: 'var(--ink-3)',
lineHeight: 1.8,
}}
>
{event.description}
</p>
</div>
{/* footer */}
<div
style={{
borderTop: '1px solid var(--rule-thin)',
padding: '12px 20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<div style={{ borderTop: '1px solid var(--rule-thin)', padding: '12px 20px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
{event.registrationOpen ? (
<a
href="#"
style={{
fontSize: 13,
fontWeight: 700,
color: 'var(--red)',
textDecoration: 'none',
letterSpacing: '-0.2px',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
ثبتنام
<span style={{ fontSize: 15 }}></span>
<a href="#" style={{ fontSize: 13, fontWeight: 700, color: 'var(--red)', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 4 }}>
{lang === 'fa' ? 'ثبت‌نام' : 'Register'}<span style={{ fontSize: 15 }}></span>
</a>
) : (
<span
style={{
fontSize: 11,
fontWeight: 500,
color: 'var(--ink-5)',
letterSpacing: '0.5px',
}}
>
بهزودی
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--ink-5)' }}>
{lang === 'fa' ? 'به‌زودی' : 'Coming Soon'}
</span>
)}
</div>
@ -202,120 +90,39 @@ function EventCard({ event }: { event: (typeof events)[0] }) {
)
}
/* ─── main component ─────────────────────────────────────── */
export default function Events() {
const [activeFilter, setActiveFilter] = useState<FilterValue>('همه')
const { lang } = useLang()
const t = T[lang]
const filters = FILTERS[lang]
const [activeFilter, setActiveFilter] = useState<FilterValue>('all')
const filtered = activeFilter === 'همه'
? events
: events.filter((e) => e.type === activeFilter)
const filtered = activeFilter === 'all' ? events : events.filter(e => e.type === activeFilter)
return (
<div
style={{
maxWidth: 1280,
margin: '0 auto',
padding: '64px 48px',
direction: 'rtl',
}}
>
{/* ─── Page title banner ─────────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'stretch',
borderBottom: '3px solid var(--ink)',
marginBottom: 36,
}}
>
<div
style={{
background: 'var(--ink)',
padding: '14px 28px',
display: 'flex',
alignItems: 'center',
}}
>
<h1
style={{
fontSize: 24,
fontWeight: 900,
color: 'var(--paper)',
letterSpacing: '-0.5px',
whiteSpace: 'nowrap',
}}
>
رویدادهای پیشرو
</h1>
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '64px 48px', direction: lang === 'fa' ? 'rtl' : 'ltr' }}>
<div style={{ display: 'flex', alignItems: 'stretch', borderBottom: '3px solid var(--ink)', marginBottom: 36 }}>
<div style={{ background: 'var(--ink)', padding: '14px 28px', display: 'flex', alignItems: 'center' }}>
<h1 style={{ fontSize: 24, fontWeight: 900, color: 'var(--paper)', letterSpacing: '-0.5px', whiteSpace: 'nowrap' }}>{t.heading}</h1>
</div>
<div style={{ flex: 1 }} />
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '14px 0 14px 4px',
}}
>
<span
style={{
fontSize: 13,
fontWeight: 500,
color: 'var(--ink-4)',
letterSpacing: '0.5px',
}}
>
۱۴۰۳۱۴۰۴
</span>
<div style={{ display: 'flex', alignItems: 'center', padding: '14px 0 14px 4px' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--ink-4)' }}>{t.year}</span>
</div>
</div>
{/* ─── Filter tabs ───────────────────────────────────── */}
<div
style={{
display: 'flex',
gap: 0,
borderBottom: '1px solid var(--rule-thin)',
marginBottom: 40,
overflowX: 'auto',
scrollbarWidth: 'none',
}}
>
{FILTERS.map((f) => {
<div style={{ display: 'flex', gap: 0, borderBottom: '1px solid var(--rule-thin)', marginBottom: 40, overflowX: 'auto', scrollbarWidth: 'none' }}>
{filters.map(f => {
const isActive = activeFilter === f.value
return (
<button
key={f.value}
onClick={() => setActiveFilter(f.value)}
style={{
padding: '10px 24px',
fontSize: 13,
fontWeight: isActive ? 700 : 500,
border: 'none',
borderBottom: isActive ? '2px solid transparent' : '2px solid transparent',
background: isActive ? 'var(--ink)' : 'transparent',
color: isActive ? 'var(--paper)' : 'var(--ink-4)',
cursor: 'pointer',
fontFamily: 'inherit',
whiteSpace: 'nowrap',
flexShrink: 0,
transition: 'all 120ms',
position: 'relative',
marginBottom: -1,
outline: 'none',
}}
style={{ padding: '10px 24px', fontSize: 13, fontWeight: isActive ? 700 : 500, border: 'none', background: isActive ? 'var(--ink)' : 'transparent', color: isActive ? 'var(--paper)' : 'var(--ink-4)', cursor: 'pointer', fontFamily: 'inherit', whiteSpace: 'nowrap', flexShrink: 0, transition: 'all 120ms', marginBottom: -1, outline: 'none' }}
>
{f.label}
{f.value !== 'همه' && (
<span
style={{
marginRight: 6,
fontSize: 10,
color: isActive ? 'rgba(244,239,231,0.6)' : 'var(--ink-6)',
}}
>
({events.filter((e) => e.type === f.value).length})
{f.value !== 'all' && (
<span style={{ marginLeft: 6, fontSize: 10, color: isActive ? 'rgba(244,239,231,0.6)' : 'var(--ink-6)' }}>
({events.filter(e => e.type === f.value).length})
</span>
)}
</button>
@ -323,45 +130,18 @@ export default function Events() {
})}
</div>
{/* ─── Events grid ───────────────────────────────────── */}
<div
className="events-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: 1,
background: 'var(--rule-thin)',
}}
>
{filtered.map((event) => (
<EventCard key={event.id} event={event} />
))}
{/* fill odd trailing cell */}
{filtered.length % 2 !== 0 && (
<div style={{ background: 'var(--paper)' }} />
)}
<div className="events-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1, background: 'var(--rule-thin)' }}>
{filtered.map(event => <EventCard key={event.id} event={event} lang={lang} />)}
{filtered.length % 2 !== 0 && <div style={{ background: 'var(--paper)' }} />}
</div>
{filtered.length === 0 && (
<div
style={{
padding: '80px 0',
textAlign: 'center',
color: 'var(--ink-5)',
}}
>
<p style={{ fontSize: 15, fontWeight: 300 }}>رویدادی در این دستهبندی یافت نشد</p>
<div style={{ padding: '80px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
<p style={{ fontSize: 15, fontWeight: 300 }}>{t.empty}</p>
</div>
)}
{/* responsive */}
<style>{`
@media (max-width: 767px) {
.events-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
<style>{`@media (max-width: 767px) { .events-grid { grid-template-columns: 1fr !important; } }`}</style>
</div>
)
}

View File

@ -1,106 +1,37 @@
import HeroSection from './sections/HeroSection'
import SnapshotBar from './sections/SnapshotBar'
import GlobalScannerSection from './sections/GlobalScannerSection'
import ReportsCarouselSection from './sections/ReportsCarouselSection'
import LatestNewsBentoSection from './sections/LatestNewsBentoSection'
import SteelNewsletterSection from './sections/SteelNewsletterSection'
import EventsSection from './sections/EventsSection'
import FactoryBentoSection from './sections/FactoryBentoSection'
import RiskSection from './sections/RiskSection'
import TeamSection from './sections/TeamSection'
import PlansSection from './sections/PlansSection'
import PartnersSection from './sections/PartnersSection'
import NewsletterSection from './sections/NewsletterSection'
import { useLang } from '@/context/LangContext'
/* Section chapter marker
A thin full-bleed dark bar that stamps the start of every
major section creates instant, unmistakable boundaries.
*/
/* Minimal chapter mark — thin strip, matches next section's bg, no duplicate title */
function SectionLabel({
n,
bg = 'paper',
}: {
n: string
/** matches next section bg so there's no color seam */
bg?: 'paper' | 'paper-2'
title?: string // ignored — editorial h2 inside each section owns the title
}) {
function SectionLabel({ n, bg = 'paper' }: { n: string; bg?: 'paper' | 'paper-2' }) {
return (
<div
style={{
background: `var(--${bg})`,
borderTop: '1px solid var(--rule-thin)',
}}
>
<div
className="max-w-7xl mx-auto px-12 max-md:px-5"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
paddingTop: 32,
paddingBottom: 8,
}}
>
{/* Left (visual end in RTL): chapter index */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span
style={{
fontSize: 48,
fontWeight: 900,
color: 'var(--red)',
letterSpacing: '-2px',
lineHeight: 0.85,
direction: 'ltr',
fontVariantNumeric: 'tabular-nums',
}}
className="max-md:text-[32px]"
>
{n}
</span>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 2,
lineHeight: 1.2,
}}
>
<span
style={{
fontSize: 9,
fontWeight: 700,
letterSpacing: '2.5px',
color: 'var(--ink-5)',
direction: 'ltr',
fontFamily: 'ui-monospace, monospace',
}}
>
CHAPTER
</span>
<span
style={{
<div style={{ background: `var(--${bg})`, paddingTop: 44 }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5">
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.5px',
color: 'var(--ink-4)',
}}
>
بخش {n}
</span>
fontWeight: 800,
color: 'var(--red)',
letterSpacing: '2px',
fontFamily: 'ui-monospace, monospace',
direction: 'ltr',
border: '1.5px solid var(--red)',
padding: '3px 9px',
flexShrink: 0,
lineHeight: 1.5,
}}>
{n}
</div>
<div style={{ flex: 1, height: 1, background: 'var(--rule-thin)' }} />
</div>
{/* Right (visual start RTL): thin rule fills */}
<div
style={{
flex: 1,
height: 1,
background: 'var(--rule-thin)',
maxWidth: 280,
}}
className="max-md:hidden"
/>
</div>
</div>
)
@ -108,48 +39,34 @@ function SectionLabel({
/* ─── Home page ──────────────────────────────────────── */
export default function Home() {
const { lang } = useLang()
return (
<div>
<div dir={lang === 'fa' ? 'rtl' : 'ltr'}>
{/* ── 00 · Hero ─────────────────────────────────── */}
<HeroSection />
{/* ── Ticker ────────────────────────────────────── */}
<SnapshotBar />
{/* ── 01 · اسکنر جهانی · bg: paper ─────────────── */}
<SectionLabel n="01" bg="paper" />
{/* ── 01 · اسکنر جهانی ──────────────────────────── */}
<SectionLabel n="۰۱" bg="paper" />
<GlobalScannerSection />
{/* ── 02 · تحلیل‌ها · bg: paper-2 ──────────────── */}
<SectionLabel n="02" bg="paper-2" />
<ReportsCarouselSection />
{/* ── 02 · خبرنامه فولاد ────────────────────────── */}
<SectionLabel n="۰۲" bg="paper-2" />
<SteelNewsletterSection />
{/* ── 03 · اخبار · bg: paper ────────────────────── */}
<SectionLabel n="03" bg="paper" />
<LatestNewsBentoSection />
{/* ── 04 · رویدادها · bg: paper-2 ──────────────── */}
<SectionLabel n="04" bg="paper-2" />
{/* ── 03 · آخرین رویدادها ───────────────────────── */}
<SectionLabel n="۰۳" bg="paper-2" />
<EventsSection />
{/* ── 05 · ریسک · bg: paper ─────────────────────── */}
<SectionLabel n="05" bg="paper" />
{/* ── 04 · گزارش کارخانه‌ها ─────────────────────── */}
<SectionLabel n="۰۴" bg="paper-2" />
<FactoryBentoSection />
{/* ── 05 · نقشه ریسک ────────────────────────────── */}
<SectionLabel n="۰۵" bg="paper" />
<RiskSection />
{/* ── 06 · تیم · bg: paper-2 ────────────────────── */}
<SectionLabel n="06" bg="paper-2" />
<TeamSection />
{/* ── 07 · اشتراک · bg: paper ───────────────────── */}
<SectionLabel n="07" bg="paper" />
<PlansSection />
{/* ── 08 · همراهان · bg: paper-2 ────────────────── */}
<SectionLabel n="08" bg="paper-2" />
<PartnersSection />
{/* ── خبرنامه (last, no label) ───────────────────── */}
<NewsletterSection />
</div>
)
}

View File

@ -1,12 +1,32 @@
import { Link } from 'react-router-dom'
import { events } from '@/data/events'
import type { Event } from '@/data/events'
import { useLang } from '@/context/LangContext'
const typeLabel: Record<Event['type'], string> = {
conference: 'کنگره',
exhibition: 'نمایشگاه',
seminar: 'سمینار',
international: 'جهانی',
const typeLabel: Record<Event['type'], { fa: string; en: string }> = {
conference: { fa: 'کنگره', en: 'Conference' },
exhibition: { fa: 'نمایشگاه', en: 'Exhibition' },
seminar: { fa: 'سمینار', en: 'Seminar' },
international: { fa: 'جهانی', en: 'International' },
}
const T = {
fa: {
overline: 'تقویم رویداد · ۱۴۰۳–۱۴۰۴',
heading: 'رویدادهای پیش‌رو',
sub: 'همایش‌ها، نمایشگاه‌ها و نشست‌های تخصصی صنعت فولاد در ماه‌های آینده — برای حضور یا حمایت رسانه‌ای.',
all: 'تقویم کامل ←',
register: 'ثبت‌نام ←',
soon: 'به‌زودی',
},
en: {
overline: 'EVENT CALENDAR · 14031404',
heading: 'Upcoming Events',
sub: 'Conferences, exhibitions and specialized steel industry meetings — for attendance or media sponsorship.',
all: 'Full Calendar →',
register: 'Register →',
soon: 'Coming Soon',
},
}
const typeBorderColor: Record<Event['type'], string> = {
@ -24,6 +44,8 @@ const typeTextColor: Record<Event['type'], string> = {
}
export default function EventsSection() {
const { lang } = useLang()
const t = T[lang]
const displayed = events.slice(0, 4)
return (
@ -46,18 +68,18 @@ export default function EventsSection() {
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{ width: 32, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
تقویم رویداد · ۱۴۰۳۱۴۰۴
{t.overline}
</span>
</div>
<h2 style={{ fontSize: 'clamp(28px, 3.6vw, 48px)', fontWeight: 900, letterSpacing: '-1.2px', color: 'var(--ink)', lineHeight: 1.1, marginBottom: 12 }}>
رویدادهای پیشرو
<h2 style={{ fontSize: 'clamp(22px, 2.5vw, 32px)', fontWeight: 900, letterSpacing: '-0.5px', color: 'var(--ink)', lineHeight: 1.2, marginBottom: 12 }}>
{t.heading}
</h2>
<p style={{ fontSize: 14, lineHeight: 1.7, color: 'var(--ink-4)', maxWidth: 560 }}>
همایشها، نمایشگاهها و نشستهای تخصصی صنعت فولاد در ماههای آینده برای حضور یا حمایت رسانهای.
{t.sub}
</p>
</div>
<Link to="/events" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '1.5px', textTransform: 'uppercase', color: 'var(--red)', textDecoration: 'none', whiteSpace: 'nowrap', paddingBottom: 4 }}>
تقویم کامل
{t.all}
</Link>
</div>
@ -97,7 +119,7 @@ export default function EventsSection() {
lineHeight: 1.4,
}}
>
{typeLabel[event.type]}
{typeLabel[event.type][lang]}
</span>
</div>
@ -110,11 +132,11 @@ export default function EventsSection() {
{/* CTA */}
{event.registrationOpen ? (
<Link to="#" style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', color: 'var(--red)', textDecoration: 'none', cursor: 'pointer' }}>
ثبتنام
{t.register}
</Link>
) : (
<span style={{ fontSize: 10, color: 'var(--ink-5)', textTransform: 'uppercase', fontWeight: 700 }}>
بهزودی
{t.soon}
</span>
)}
</div>

View File

@ -0,0 +1,158 @@
import { BentoGrid, BentoGridItem } from '@/components/ui/bento-grid'
import { useLang } from '@/context/LangContext'
type FactoryReport = {
publisher: string
publisherEn: string
title: string
titleEn: string
description: string
descriptionEn: string
date: string
dateEn: string
accentColor: string
imgBg: string
}
const REPORTS: FactoryReport[] = [
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'تحلیل عملکرد تولید و صادرات — بهمن ۱۴۰۳',
titleEn: 'Production & Export Performance — Feb 2025',
description: 'بررسی آمار تولید ماهانه، مقایسه با بودجه سالانه و تحلیل روند صادرات فولاد تخت در بازارهای منطقه‌ای',
descriptionEn: 'Monthly production figures, annual budget comparison, and flat steel export trend analysis across regional markets.',
date: 'بهمن ۱۴۰۳', dateEn: 'Feb 2025',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.13) 0%, rgba(180,20,20,0.04) 100%)',
},
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'وضعیت بازار و قیمت‌گذاری — دی ۱۴۰۳',
titleEn: 'Market Conditions & Pricing — Jan 2025',
description: 'تحلیل نوسانات قیمت فولاد تخت در بازار داخلی، بررسی اثر نرخ ارز و چشم‌انداز بازار در فصل زمستان',
descriptionEn: 'Flat steel price fluctuations in the domestic market, exchange rate impact analysis, and winter market outlook.',
date: 'دی ۱۴۰۳', dateEn: 'Jan 2025',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.11) 0%, rgba(180,20,20,0.03) 100%)',
},
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'گزارش پایداری و محیط زیست — آذر ۱۴۰۳',
titleEn: 'Sustainability & Environment Report — Dec 2024',
description: 'شاخص‌های زیست‌محیطی ماهانه؛ مصرف انرژی، کاهش انتشار کربن و عملکرد سیستم بازیافت پساب صنعتی',
descriptionEn: 'Monthly environmental KPIs: energy consumption, carbon emission reductions, and industrial wastewater recycling performance.',
date: 'آذر ۱۴۰۳', dateEn: 'Dec 2024',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.09) 0%, rgba(180,20,20,0.03) 100%)',
},
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'تحلیل نیروی انسانی و بهره‌وری — آبان ۱۴۰۳',
titleEn: 'Workforce & Productivity Analysis — Nov 2024',
description: 'بررسی شاخص‌های بهره‌وری نیروی کار، آموزش‌های ماهانه و آمار ایمنی شغلی و حوادث محیط کار',
descriptionEn: 'Workforce productivity indicators, monthly training hours, occupational safety statistics, and workplace incident rates.',
date: 'آبان ۱۴۰۳', dateEn: 'Nov 2024',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.13) 0%, rgba(180,20,20,0.04) 100%)',
},
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'عملکرد مالی و سرمایه‌گذاری — مهر ۱۴۰۳',
titleEn: 'Financial Performance & Investment — Oct 2024',
description: 'خلاصه عملکرد مالی، پروژه‌های جاری سرمایه‌گذاری و پیشرفت طرح توسعه تا پایان مهرماه ۱۴۰۳',
descriptionEn: 'Financial performance summary, active investment projects, and development plan progress through end of October 2024.',
date: 'مهر ۱۴۰۳', dateEn: 'Oct 2024',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.10) 0%, rgba(180,20,20,0.03) 100%)',
},
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'فناوری و نوآوری در فرآیند تولید — شهریور ۱۴۰۳',
titleEn: 'Technology & Innovation in Production — Sep 2024',
description: 'گزارش پیشرفت پروژه‌های دیجیتال‌سازی، بهره‌گیری از هوش مصنوعی در کنترل کیفیت و اتوماسیون خط تولید',
descriptionEn: 'Digitalization project progress, AI adoption in quality control, and production line automation milestones.',
date: 'شهریور ۱۴۰۳', dateEn: 'Sep 2024',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.12) 0%, rgba(180,20,20,0.04) 100%)',
},
{
publisher: 'فولاد مبارکه', publisherEn: 'Mobarakeh Steel',
title: 'زنجیره تأمین و مواد اولیه — مرداد ۱۴۰۳',
titleEn: 'Supply Chain & Raw Materials — Aug 2024',
description: 'تحلیل وضعیت تأمین سنگ‌آهن، آهن اسفنجی و کُک؛ بررسی ریسک‌های زنجیره تأمین در فصل تابستان',
descriptionEn: 'Iron ore, DRI, and coke supply status; supply chain risk assessment for the summer season.',
date: 'مرداد ۱۴۰۳', dateEn: 'Aug 2024',
accentColor: 'var(--red)',
imgBg: 'linear-gradient(135deg, rgba(180,20,20,0.08) 0%, rgba(180,20,20,0.03) 100%)',
},
]
const T = {
fa: { overline: 'MOBARAKEH STEEL', heading: 'ماهنامه تحلیلی کارخانه', all: 'مشاهده همه ←' },
en: { overline: 'MOBARAKEH STEEL', heading: 'Factory Monthly Analysis', all: 'View All →' },
}
function ReportCover({ report, lang }: { report: FactoryReport; lang: 'fa' | 'en' }) {
return (
<div
style={{
width: '100%', minHeight: 160, background: report.imgBg,
borderBottom: `2px solid ${report.accentColor}33`,
display: 'flex', flexDirection: 'column', justifyContent: 'flex-end',
padding: '16px 20px', position: 'relative', overflow: 'hidden',
}}
>
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: 52, fontWeight: 900, letterSpacing: '-3px',
color: report.accentColor, opacity: 0.07, whiteSpace: 'nowrap',
userSelect: 'none', direction: 'ltr',
}}>
{report.publisherEn.toUpperCase()}
</div>
<div style={{
display: 'inline-flex', alignSelf: 'flex-start',
background: report.accentColor, color: '#fff',
fontSize: 9, fontWeight: 700, letterSpacing: '1.5px',
padding: '4px 10px', textTransform: 'uppercase',
}}>
{lang === 'fa' ? report.publisher : report.publisherEn}
</div>
</div>
)
}
export default function FactoryBentoSection() {
const { lang } = useLang()
const t = T[lang]
return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper-2)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 64 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 28, flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.overline}</div>
<h2 style={{ fontSize: 'clamp(22px,2.5vw,32px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>{t.heading}</h2>
</div>
<a href="#" style={{ fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>{t.all}</a>
</div>
<BentoGrid>
{REPORTS.map((report, i) => (
<BentoGridItem
key={i}
header={<ReportCover report={report} lang={lang} />}
tag={`${lang === 'fa' ? report.date : report.dateEn} · ${lang === 'fa' ? 'ماهنامه تحلیلی کارخانه' : 'Monthly Factory Analysis'}`}
tagColor={report.accentColor}
title={lang === 'fa' ? report.title : report.titleEn}
description={lang === 'fa' ? report.description : report.descriptionEn}
accentLine
className={i === 3 || i === 6 ? 'md:col-span-2' : ''}
/>
))}
</BentoGrid>
</div>
</section>
)
}

View File

@ -1,5 +1,29 @@
import { motion } from 'framer-motion'
import WorldMap from '@/components/ui/world-map'
import { useLang } from '@/context/LangContext'
const T = {
fa: {
h2a: 'تمام رویدادهای فولادی دنیا', h2b: ' را رصد می‌کنیم',
sub: 'از بورس دالیان چین تا گمرک بصره عراق، از تصمیمات کمیته فولاد OECD تا ظرفیت‌سازی‌های هند — هر سیگنالی که بر رقابت‌پذیری ایران اثر می‌گذارد، پیش از بقیه به دست شما می‌رسد.',
stats: [
{ n: '۱۸۰+', label: 'بازار رصد‌شده', sub: 'در ۵ قاره' },
{ n: '۷/۲۴', label: 'پایش لحظه‌ای', sub: 'قیمت و حجم' },
{ n: '۴۰+', label: 'منبع داده', sub: 'بورس‌ها و گمرک‌ها' },
{ n: '۱۵', label: 'کشور رقیب', sub: 'زیر ذره‌بین' },
],
},
en: {
h2a: 'Every Global Steel Event,', h2b: ' Monitored',
sub: "From the Dalian Commodity Exchange to the Basra customs gate, from OECD Steel Committee decisions to India's capacity build-up — every signal affecting Iran's competitiveness reaches you first.",
stats: [
{ n: '180+', label: 'Markets Tracked', sub: 'Across 5 continents' },
{ n: '24/7', label: 'Live Monitoring', sub: 'Price & volume' },
{ n: '40+', label: 'Data Sources', sub: 'Exchanges & customs' },
{ n: '15', label: 'Rival Countries', sub: 'Under the lens' },
],
},
}
/* Steel trade routes
Arcs show geopolitically relevant steel flows affecting Iran's market
@ -48,6 +72,8 @@ const ROUTES = [
/* ─── Section ────────────────────────────────────────── */
export default function GlobalScannerSection() {
const { lang } = useLang()
const t = T[lang]
return (
<section
style={{
@ -62,14 +88,14 @@ export default function GlobalScannerSection() {
>
{/* Headline */}
<h2 style={{
fontSize: 'clamp(28px, 3.5vw, 52px)',
fontSize: 'clamp(22px, 2.5vw, 32px)',
fontWeight: 900,
lineHeight: 1.1,
letterSpacing: '-1.5px',
color: 'var(--ink)',
marginBottom: 16,
}}>
تمام رویدادهای فولادی دنیا{' '}
{t.h2a}{' '}
<motion.span
style={{ color: 'var(--ink-6)', display: 'inline' }}
initial={{ opacity: 0 }}
@ -77,7 +103,7 @@ export default function GlobalScannerSection() {
viewport={{ once: true }}
transition={{ duration: 0.8, delay: 0.3 }}
>
را رصد میکنیم
{t.h2b}
</motion.span>
</h2>
@ -90,9 +116,7 @@ export default function GlobalScannerSection() {
maxWidth: 600,
margin: '0 auto',
}}>
از بورس دالیان چین تا گمرک بصره عراق، از تصمیمات کمیته فولاد OECD
تا ظرفیتسازیهای هند هر سیگنالی که بر رقابتپذیری ایران اثر
میگذارد، پیش از بقیه به دست شما میرسد.
{t.sub}
</p>
</div>
@ -113,12 +137,7 @@ export default function GlobalScannerSection() {
}}
className="grid grid-cols-4 max-md:grid-cols-2 max-sm:grid-cols-1"
>
{[
{ n: '۱۸۰+', label: 'بازار رصد‌شده', sub: 'در ۵ قاره' },
{ n: '۷/۲۴', label: 'پایش لحظه‌ای', sub: 'قیمت و حجم' },
{ n: '۴۰+', label: 'منبع داده', sub: 'بورس‌ها و گمرک‌ها' },
{ n: '۱۵', label: 'کشور رقیب', sub: 'زیر ذره‌بین' },
].map((s, i) => (
{t.stats.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}

View File

@ -1,424 +1,122 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { reports } from '@/data/reports'
import { useLang } from '@/context/LangContext'
const featured = reports.find(r => r.featured)!
const STATS = [
{ n: '۴', label: 'سناریو محتمل' },
{ n: '۱۵', label: 'کشور رقیب' },
{ n: '۸۴', label: 'صفحه تحلیلی' },
{ n: '۱۲', label: 'توصیه سیاستی' },
]
const T = {
fa: {
h1a: 'جایی که آینده', h1b: ' به تصمیم ', h1c: 'تبدیل می‌شود',
sub: 'تحلیل روندها، فناوری‌ها و سناریوهای آینده صنعت فولاد',
cta1: 'گزارش ویژه ←', cta2: 'نبض صنعت',
video: 'Short film · Future of Steel',
},
en: {
h1a: 'Where the Future', h1b: ' Becomes ', h1c: 'Decision',
sub: 'Analyzing trends, technologies and future scenarios of the global steel industry',
cta1: 'Special Report →', cta2: 'Industry Pulse',
video: 'Short film · Future of Steel',
},
}
export default function HeroSection() {
const [buyHov, setBuyHov] = useState(false)
const [freeHov, setFreeHov] = useState(false)
const { lang } = useLang()
const t = T[lang]
const [repHov, setRepHov] = useState(false)
const [pulseHov, setPulseHov] = useState(false)
return (
<section
style={{
position: 'relative',
background: 'var(--paper)',
borderBottom: '3px solid var(--ink)',
background: 'var(--ink)',
borderBottom: '3px solid var(--red)',
overflow: 'hidden',
minHeight: '80vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{/* Subtle grid texture */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: `
linear-gradient(to right, rgba(26,23,18,0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(26,23,18,0.04) 1px, transparent 1px)
`,
backgroundSize: '64px 64px',
pointerEvents: 'none',
}}
/>
{/* Dark overlay */}
<div aria-hidden="true" style={{ position: 'absolute', inset: 0, background: 'linear-gradient(135deg, rgba(26,23,18,0.92) 0%, rgba(26,23,18,0.70) 100%)', zIndex: 1 }} />
{/* Grid texture */}
<div aria-hidden="true" style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(to right, rgba(244,239,231,0.03) 1px, transparent 1px), linear-gradient(to bottom, rgba(244,239,231,0.03) 1px, transparent 1px)', backgroundSize: '80px 80px', pointerEvents: 'none', zIndex: 2 }} />
{/* Red glow */}
<div aria-hidden="true" style={{ position: 'absolute', inset: 0, backgroundImage: 'radial-gradient(ellipse 80% 60% at 60% 50%, rgba(180,20,20,0.12) 0%, transparent 70%)', zIndex: 1 }} />
<div
className="relative max-w-7xl mx-auto px-12 max-md:px-5"
style={{ paddingTop: 56, paddingBottom: 64 }}
style={{ paddingTop: 80, paddingBottom: 88, position: 'relative', zIndex: 3 }}
>
{/* ─── Top meta row ─────────────────────────── */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 24,
borderBottom: '1px solid var(--rule-thin)',
marginBottom: 48,
}}
className="max-md:flex-wrap max-md:gap-3 max-md:mb-8"
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<div style={{
background: 'var(--red)',
color: 'white',
fontSize: 9,
fontWeight: 700,
letterSpacing: '2.5px',
padding: '5px 12px',
textTransform: 'uppercase',
}}>
گزارش ویژه · شماره ۰۱
</div>
<span style={{
fontSize: 10,
color: 'var(--ink-5)',
letterSpacing: '1.5px',
textTransform: 'uppercase',
fontWeight: 600,
}}>
{featured.publishDate} · {featured.category}
</span>
</div>
<span
style={{
fontSize: 10,
color: 'var(--ink-5)',
fontWeight: 600,
letterSpacing: '1.5px',
fontFamily: 'ui-monospace, monospace',
direction: 'ltr',
}}
className="max-md:hidden"
>
ISSN 2783-XXXX · VOL. XII
</span>
</div>
{/* ─── Main grid — 8/12 + 4/12 ─────────────── */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 320px',
gap: 56,
alignItems: 'start',
}}
className="mq-stack max-md:grid-cols-1 max-md:gap-10"
>
{/* ═══ Left col ═══ */}
<div>
{/* Overline */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 28 }}
style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 32 }}
>
<div style={{ width: 40, height: 2, background: 'var(--red)' }} />
<span style={{
fontSize: 11,
fontWeight: 700,
letterSpacing: '3px',
textTransform: 'uppercase',
color: 'var(--ink-4)',
}}>
تحلیل سناریو · راهبردی
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'rgba(244,239,231,0.5)' }}>
FUTURE STEEL POLICY INSTITUTE
</span>
</motion.div>
{/* Massive editorial headline */}
{/* Headline */}
<motion.h1
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.08 }}
style={{
fontSize: 'clamp(34px, 5.2vw, 76px)',
fontWeight: 900,
lineHeight: 1.05,
letterSpacing: '-1.5px',
color: 'var(--ink)',
marginBottom: 28,
}}
transition={{ duration: 0.6, delay: 0.08 }}
style={{ fontSize: 'clamp(36px, 5.5vw, 80px)', fontWeight: 900, lineHeight: 1.05, letterSpacing: '-2px', color: 'var(--paper)', marginBottom: 28, maxWidth: 820 }}
>
{(() => {
const [main, sub] = featured.title.split(':')
return (
<>
<span style={{ display: 'block' }}>{main}</span>
{sub && (
<span style={{
display: 'block',
color: 'var(--red)',
fontWeight: 900,
marginTop: 8,
}}>
{sub.trim()}
</span>
)}
</>
)
})()}
{t.h1a}
<span style={{ color: 'var(--red)' }}>{t.h1b}</span>
{t.h1c}
</motion.h1>
{/* Lede */}
{/* Subtitle */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.18 }}
style={{
fontSize: 'clamp(15px, 1.2vw, 18px)',
lineHeight: 1.7,
fontWeight: 400,
color: 'var(--ink-3)',
maxWidth: 620,
marginBottom: 36,
borderRight: '2px solid var(--red)',
paddingRight: 16,
}}
transition={{ duration: 0.5, delay: 0.2 }}
style={{ fontSize: 'clamp(15px, 1.3vw, 19px)', lineHeight: 1.75, fontWeight: 400, color: 'rgba(244,239,231,0.6)', maxWidth: 560, marginBottom: 48, borderRight: lang === 'fa' ? '2px solid var(--red)' : 'none', borderLeft: lang === 'en' ? '2px solid var(--red)' : 'none', paddingRight: lang === 'fa' ? 18 : 0, paddingLeft: lang === 'en' ? 18 : 0 }}
>
مدلسازی کمی بر اساس دادههای تجارت ۱۵ کشور رقیب از بدبینانه تا خوشبینانه،
با توصیههای سیاستی مستقیم برای وزارت صمت و سازمان بنادر.
{t.sub}
</motion.p>
{/* Stats inline row */}
{/* CTAs */}
<motion.div
initial={{ opacity: 0, y: 8 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: 0,
borderTop: '1px solid var(--rule-thin)',
borderBottom: '1px solid var(--rule-thin)',
marginBottom: 36,
}}
className="mq-stack-2 max-md:grid-cols-2"
transition={{ duration: 0.4, delay: 0.32 }}
style={{ display: 'flex', gap: 14, flexWrap: 'wrap' }}
>
{STATS.map((s, i) => (
<div
key={i}
style={{
padding: '16px 12px 16px 0',
borderRight: i < STATS.length - 1 ? '1px solid var(--rule-thin)' : 'none',
}}
className={i < 2 ? 'max-md:border-b max-md:border-[var(--rule-thin)]' : ''}
>
<div style={{
fontSize: 'clamp(24px, 2.8vw, 36px)',
fontWeight: 900,
letterSpacing: '-1px',
color: 'var(--ink)',
lineHeight: 1,
fontVariantNumeric: 'tabular-nums',
}}>
{s.n}
</div>
<div style={{
fontSize: 10,
color: 'var(--ink-5)',
fontWeight: 600,
letterSpacing: '0.5px',
marginTop: 6,
}}>
{s.label}
</div>
</div>
))}
</motion.div>
{/* Author + CTAs */}
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.35 }}
style={{
display: 'flex',
alignItems: 'center',
gap: 24,
flexWrap: 'wrap',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 40, height: 40,
background: 'var(--ink)',
color: 'var(--paper)',
fontSize: 14, fontWeight: 900,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
borderRadius: '50%',
}}>
{featured.authorInitial}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.2 }}>
{featured.author}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-5)', lineHeight: 1.3, marginTop: 2 }}>
{featured.authorRole}
</div>
</div>
</div>
<div className="max-sm:hidden" style={{ width: 1, height: 36, background: 'var(--rule-thin)' }} />
<div className="flex gap-2.5 max-sm:flex-col max-sm:w-full">
<button
onMouseEnter={() => setBuyHov(true)}
onMouseLeave={() => setBuyHov(false)}
style={{
background: buyHov ? 'var(--red)' : 'var(--ink)',
color: 'var(--paper)',
border: 'none',
padding: '12px 28px',
fontSize: 12,
fontWeight: 700,
fontFamily: 'Vazir, sans-serif',
cursor: 'pointer',
transition: 'background 160ms',
whiteSpace: 'nowrap',
letterSpacing: '0.3px',
}}
className="max-sm:w-full"
onMouseEnter={() => setRepHov(true)}
onMouseLeave={() => setRepHov(false)}
style={{ background: repHov ? 'var(--red)' : 'var(--paper)', color: repHov ? 'var(--paper)' : 'var(--ink)', border: 'none', padding: '14px 32px', fontSize: 13, fontWeight: 700, fontFamily: 'Vazir, sans-serif', cursor: 'pointer', transition: 'background 160ms, color 160ms', whiteSpace: 'nowrap', letterSpacing: '0.3px' }}
>
خرید گزارش · {featured.price.toLocaleString('fa-IR')} تومان
{t.cta1}
</button>
<button
onMouseEnter={() => setFreeHov(true)}
onMouseLeave={() => setFreeHov(false)}
style={{
background: freeHov ? 'var(--ink)' : 'transparent',
color: freeHov ? 'var(--paper)' : 'var(--ink)',
border: '1px solid var(--ink)',
padding: '12px 24px',
fontSize: 12,
fontWeight: 600,
fontFamily: 'Vazir, sans-serif',
cursor: 'pointer',
transition: 'background 160ms, color 160ms',
whiteSpace: 'nowrap',
}}
className="max-sm:w-full"
onMouseEnter={() => setPulseHov(true)}
onMouseLeave={() => setPulseHov(false)}
style={{ background: 'transparent', color: 'rgba(244,239,231,0.8)', border: `1px solid ${pulseHov ? 'rgba(244,239,231,0.7)' : 'rgba(244,239,231,0.3)'}`, padding: '14px 32px', fontSize: 13, fontWeight: 600, fontFamily: 'Vazir, sans-serif', cursor: 'pointer', transition: 'border-color 160ms', whiteSpace: 'nowrap' }}
>
دانلود خلاصه رایگان
{t.cta2}
</button>
</div>
</motion.div>
</div>
{/* ═══ Right col: dossier mini-cover — frosted glass over ink ═══ */}
{/* Video placeholder badge */}
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.55, delay: 0.4 }}
style={{
position: 'relative',
background: 'rgba(26,23,18,0.78)',
backdropFilter: 'blur(24px) saturate(170%)',
WebkitBackdropFilter: 'blur(24px) saturate(170%)',
border: '1px solid rgba(244,239,231,0.08)',
color: 'var(--paper)',
padding: '36px 30px',
boxShadow: '0 24px 60px rgba(26,23,18,0.22), inset 0 1px 0 rgba(244,239,231,0.05)',
transform: 'rotate(0.6deg)',
}}
className="max-md:rotate-0"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4, delay: 0.5 }}
className="max-md:hidden"
style={{ position: 'absolute', bottom: 88, left: 48, display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(244,239,231,0.35)', fontSize: 10, letterSpacing: '2px', textTransform: 'uppercase', direction: 'ltr' }}
>
<div style={{
position: 'absolute',
top: 0, right: 0, left: 0,
height: 4,
background: 'var(--red)',
}} />
<div style={{
position: 'absolute',
top: 18,
left: 22,
fontSize: 9,
fontWeight: 700,
letterSpacing: '2px',
color: 'rgba(244,239,231,0.4)',
direction: 'ltr',
fontFamily: 'ui-monospace, monospace',
}}>
01 / 2025
</div>
<div style={{
fontSize: 10,
fontWeight: 700,
letterSpacing: '3px',
color: 'rgba(244,239,231,0.4)',
textTransform: 'uppercase',
marginBottom: 24,
marginTop: 8,
}}>
DOSSIER
</div>
<div style={{
fontSize: 120,
fontWeight: 900,
lineHeight: 0.85,
letterSpacing: '-6px',
color: 'var(--red)',
direction: 'ltr',
fontVariantNumeric: 'tabular-nums',
marginBottom: 12,
}}>
۰۴
</div>
<div style={{
fontSize: 13,
fontWeight: 700,
color: 'rgba(244,239,231,0.85)',
lineHeight: 1.4,
marginBottom: 4,
}}>
سناریو راهبردی
</div>
<div style={{
fontSize: 11,
color: 'rgba(244,239,231,0.5)',
lineHeight: 1.5,
marginBottom: 28,
}}>
برای آینده فولاد ایران در بازار جهانی ۱۴۰۴
</div>
<ul style={{
listStyle: 'none',
padding: 0,
margin: 0,
borderTop: '1px solid rgba(244,239,231,0.1)',
paddingTop: 16,
}}>
{[
{ k: 'صفحات', v: `${featured.pages.toLocaleString('fa-IR')} صفحه` },
{ k: 'نوع', v: 'گزارش راهبردی' },
{ k: 'انتشار', v: featured.publishDate },
{ k: 'مخاطب', v: 'سیاست‌گذار · مدیر ارشد' },
].map((row) => (
<li
key={row.k}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
fontSize: 11,
}}
>
<span style={{ color: 'rgba(244,239,231,0.5)' }}>{row.k}</span>
<span style={{ color: 'rgba(244,239,231,0.9)', fontWeight: 600 }}>{row.v}</span>
</li>
))}
</ul>
<div style={{ width: 36, height: 36, border: '1px solid rgba(244,239,231,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14 }}></div>
Short film · Future of Steel
</motion.div>
</div>
</div>
</section>
)
}

View File

@ -1,18 +1,58 @@
import type { CSSProperties } from 'react'
import { useEffect, useState, type CSSProperties } from 'react'
import { motion } from 'framer-motion'
import { reports } from '@/data/reports'
const featured = reports.find(r => r.featured)!
const flashReport = reports.find(r => r.type === 'flash')!
/* ─── Price snapshot ────────────────────────────────────── */
const PRICES = [
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env
?.VITE_PANEL_API || 'http://localhost:3001'
type PriceRow = { label: string; value: string; delta: string; up: boolean }
const FALLBACK_PRICES: PriceRow[] = [
{ label: 'HRC آسیا', value: '۵۹۰', delta: '+۳.۲٪', up: true },
{ label: 'میلگرد ایران', value: '۲۱,۸۰۰', delta: '+۰.۸٪', up: true },
{ label: 'سنگ‌آهن دالیان', value: '۱۰۸', delta: '+۱۸٪', up: true },
{ label: 'HRC اروپا', value: '۶۲۴', delta: '۱.۴٪', up: false },
]
type ApiPrice = {
symbol: string
name: string
value: number | null
changePct: number | null
}
function toPersianDigits(s: string): string {
return s.replace(/[0-9]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[Number(d)])
}
function formatNumber(n: number): string {
const fixed = Math.abs(n) >= 1000
? n.toLocaleString('en-US')
: n.toFixed(Math.abs(n) >= 10 ? 0 : 2)
return toPersianDigits(fixed)
}
function mapApiToRows(items: ApiPrice[]): PriceRow[] {
return items
.filter((p) => typeof p.value === 'number')
.slice(0, 4)
.map((p) => {
const pct = p.changePct ?? 0
const sign = pct > 0 ? '+' : pct < 0 ? '' : ''
const magnitude = toPersianDigits(Math.abs(pct).toFixed(1))
return {
label: p.name,
value: formatNumber(p.value as number),
delta: `${sign}${magnitude}٪`,
up: pct >= 0,
}
})
}
/* ─── Shared card style (NO gridColumn here — set via className) */
const CARD: CSSProperties = {
background: 'var(--paper)',
@ -50,6 +90,25 @@ function Accent({ color = 'var(--red)' }: { color?: string }) {
/* ─── Section ────────────────────────────────────────────── */
export default function LatestNewsBentoSection() {
const [prices, setPrices] = useState<PriceRow[]>(FALLBACK_PRICES)
useEffect(() => {
let cancelled = false
const load = () => {
fetch(`${PANEL_API}/api/prices`)
.then((r) => (r.ok ? r.json() : null))
.then((data: ApiPrice[] | null) => {
if (cancelled || !Array.isArray(data) || data.length === 0) return
const rows = mapApiToRows(data)
if (rows.length > 0) setPrices(rows)
})
.catch(() => { /* keep fallback */ })
}
load()
const id = setInterval(load, 2 * 60 * 1000)
return () => { cancelled = true; clearInterval(id) }
}, [])
return (
<section style={{ background: 'var(--paper)' }}>
@ -116,7 +175,7 @@ export default function LatestNewsBentoSection() {
آخرین نرخهای بازار
</div>
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
{PRICES.map(p => (
{prices.map(p => (
<div key={p.label} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '9px 0', borderBottom: '1px solid var(--rule-thin)' }}>
<span style={{ fontSize: 11, color: 'var(--ink-4)', fontWeight: 500 }}>{p.label}</span>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>

View File

@ -1,7 +1,16 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useLang } from '@/context/LangContext'
/* ─── Risk data ──────────────────────────────────────────── */
const riskItems = [
type RiskLevel = 'critical' | 'high' | 'medium' | 'low' | 'opportunity'
type RiskItem = { quote: string; name: string; date: string; level: RiskLevel }
const PANEL_API =
(import.meta as ImportMeta & { env?: Record<string, string> }).env
?.VITE_PANEL_API || 'http://localhost:3001'
/* ─── Risk data (fallback when panel is offline) ─────────── */
const fallbackRiskItems: RiskItem[] = [
{
quote: 'چین در نیمه اول ۲۰۲۴ رکورد ۵۴ میلیون تن صادرات فولاد را شکست. این موج صادراتی قیمت بازارهای آسیای غربی را ۱۲٪ کاهش داده و مستقیماً سهم ایران در عراق را تهدید می‌کند.',
name: 'بازار جهانی — چین',
@ -65,15 +74,58 @@ const riskItems = [
]
const LEVEL = {
fa: {
critical: { dot: '#7f1d1d', label: 'بحرانی', bg: 'rgba(127,29,29,0.08)' },
high: { dot: '#9b1c1c', label: 'بالا', bg: 'rgba(155,28,28,0.07)' },
medium: { dot: '#92400e', label: 'متوسط', bg: 'rgba(146,64,14,0.07)' },
low: { dot: '#166534', label: 'پایین', bg: 'rgba(22,101,52,0.07)' },
opportunity: { dot: '#1e40af', label: 'فرصت', bg: 'rgba(30,64,175,0.07)' },
},
en: {
critical: { dot: '#7f1d1d', label: 'Critical', bg: 'rgba(127,29,29,0.08)' },
high: { dot: '#9b1c1c', label: 'High', bg: 'rgba(155,28,28,0.07)' },
medium: { dot: '#92400e', label: 'Medium', bg: 'rgba(146,64,14,0.07)' },
low: { dot: '#166534', label: 'Low', bg: 'rgba(22,101,52,0.07)' },
opportunity: { dot: '#1e40af', label: 'Opportunity', bg: 'rgba(30,64,175,0.07)' },
},
}
const T = {
fa: {
overline: (n: number) => `پایش لحظه‌ای · ${n.toLocaleString('fa-IR')} سیگنال`,
heading: 'نقشه ریسک صنعت',
sub: 'ریسک‌های ژئوپلیتیک، مقرراتی و بازار که در ماه‌های اخیر پایش شده‌اند — با سطح‌بندی از بحرانی تا فرصت.',
all: 'همه ریسک‌ها ←',
legend: ['بحرانی', 'بالا', 'متوسط', 'پایین', 'فرصت'],
},
en: {
overline: (n: number) => `LIVE MONITORING · ${n} SIGNALS`,
heading: 'Industry Risk Map',
sub: 'Geopolitical, regulatory, and market risks monitored in recent months — rated from critical to opportunity.',
all: 'All Risks →',
legend: ['Critical', 'High', 'Medium', 'Low', 'Opportunity'],
},
}
/* ─── Section ────────────────────────────────────────────── */
export default function RiskSection() {
const { lang } = useLang()
const t = T[lang]
const levelCfg = LEVEL[lang]
const [riskItems, setRiskItems] = useState<RiskItem[]>(fallbackRiskItems)
useEffect(() => {
let cancelled = false
fetch(`${PANEL_API}/api/risks?limit=200`)
.then(r => r.ok ? r.json() : null)
.then((data: RiskItem[] | null) => {
if (cancelled || !Array.isArray(data) || data.length === 0) return
setRiskItems(data)
})
.catch(() => { /* keep fallback */ })
return () => { cancelled = true }
}, [])
/* Duplicate items for seamless infinite loop (handled in JSX, not effect, to
stay immune to StrictMode double-invocation that would otherwise clone twice). */
const loopItems = [...riskItems, ...riskItems]
@ -98,31 +150,26 @@ export default function RiskSection() {
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 18 }}>
<div style={{ width: 32, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>
پایش لحظهای · {riskItems.length.toLocaleString('fa-IR')} سیگنال
{t.overline(riskItems.length)}
</span>
</div>
<h2 style={{ fontSize: 'clamp(28px, 3.6vw, 48px)', fontWeight: 900, letterSpacing: '-1.2px', color: 'var(--ink)', lineHeight: 1.1, marginBottom: 12 }}>
نقشه ریسک صنعت
<h2 style={{ fontSize: 'clamp(22px, 2.5vw, 32px)', fontWeight: 900, letterSpacing: '-0.5px', color: 'var(--ink)', lineHeight: 1.2, marginBottom: 12 }}>
{t.heading}
</h2>
<p style={{ fontSize: 14, lineHeight: 1.7, color: 'var(--ink-4)', maxWidth: 580 }}>
ریسکهای ژئوپلیتیک، مقرراتی و بازار که در ماههای اخیر پایش شدهاند
با سطحبندی از بحرانی تا فرصت.
{t.sub}
</p>
</div>
<Link to="/risks" style={{ fontSize: 11, fontWeight: 700, letterSpacing: '1.5px', textTransform: 'uppercase', color: 'var(--red)', textDecoration: 'none', whiteSpace: 'nowrap', paddingBottom: 4 }}>
همه ریسکها
{t.all}
</Link>
</div>
{/* Legend row */}
<div style={{ display: 'flex', gap: 22, flexWrap: 'wrap', marginBottom: 8 }}>
{[
{ color: '#7f1d1d', label: 'بحرانی' },
{ color: '#9b1c1c', label: 'بالا' },
{ color: '#92400e', label: 'متوسط' },
{ color: '#166534', label: 'پایین' },
{ color: '#1e40af', label: 'فرصت' },
].map(l => (
{(['critical','high','medium','low','opportunity'] as const).map((key, i) => ({
color: levelCfg[key].dot, label: t.legend[i],
})).map(l => (
<div key={l.label} style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: l.color }} />
<span style={{ fontSize: 12, color: 'var(--ink-4)', fontWeight: 600 }}>{l.label}</span>
@ -160,7 +207,7 @@ export default function RiskSection() {
onMouseLeave={e => (e.currentTarget.style.animationPlayState = 'running')}
>
{loopItems.map((item, idx) => {
const cfg = LEVEL[item.level]
const cfg = levelCfg[item.level]
return (
<li
key={idx}

View File

@ -0,0 +1,141 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { useLang } from '@/context/LangContext'
const ISSUES = {
fa: [
{ num: '۱۲', title: 'فولاد و تحریم‌های جدید: راهکارهای صادراتی ۱۴۰۴', date: 'بهمن ۱۴۰۳' },
{ num: '۱۱', title: 'قیمت‌گذاری فولاد در بازار داخلی: چالش‌ها و فرصت‌ها', date: 'دی ۱۴۰۳' },
{ num: '۱۰', title: 'آمار تولید فولاد ایران در ۹ ماهه ۱۴۰۳', date: 'آذر ۱۴۰۳' },
],
en: [
{ num: '12', title: 'Steel & New Sanctions: Export Strategies for 1404', date: 'Feb 2025' },
{ num: '11', title: 'Steel Pricing in the Domestic Market: Challenges & Opportunities', date: 'Jan 2025' },
{ num: '10', title: "Iran Steel Production Stats — First 9 Months of 1403", date: 'Dec 2024' },
],
}
const FACTORIES = {
fa: [
{ name: 'فولاد مبارکه', capacity: '۷.۸ میلیون تن', location: 'اصفهان', type: 'فولاد تخت' },
{ name: 'ذوب‌آهن اصفهان', capacity: '۴.۲ میلیون تن', location: 'اصفهان', type: 'مقاطع سنگین' },
{ name: 'فولاد خوزستان', capacity: '۳.۲ میلیون تن', location: 'اهواز', type: 'بیلت و شمش' },
{ name: 'فولاد هرمزگان', capacity: '۱.۵ میلیون تن', location: 'هرمزگان', type: 'اسلب' },
],
en: [
{ name: 'Mobarakeh Steel', capacity: '7.8 Mt/yr', location: 'Isfahan', type: 'Flat Steel' },
{ name: 'Isfahan Steel', capacity: '4.2 Mt/yr', location: 'Isfahan', type: 'Heavy Sections' },
{ name: 'Khuzestan Steel', capacity: '3.2 Mt/yr', location: 'Ahvaz', type: 'Billet & Slab' },
{ name: 'Hormozgan Steel', capacity: '1.5 Mt/yr', location: 'Hormozgan', type: 'Slab' },
],
}
const T = {
fa: {
overline: 'STEEL NEWSLETTER', heading: 'خبرنامه فولاد', archive: 'آرشیو کامل ←',
issueLabel: 'خبرنامه ماهانه', placeholder: 'ایمیل خود را وارد کنید...',
subscribe: 'عضویت در خبرنامه', done: '✓ ثبت شد',
overlinePlants: 'MAJOR PLANTS', headingPlants: 'کارخانه‌های بزرگ فولاد ایران',
allPlants: 'مشاهده همه ←', capacity: 'ظرفیت سالانه',
},
en: {
overline: 'STEEL NEWSLETTER', heading: 'Steel Newsletter', archive: 'Full Archive →',
issueLabel: 'Monthly Issue', placeholder: 'Enter your email...',
subscribe: 'Subscribe', done: '✓ Subscribed',
overlinePlants: 'MAJOR PLANTS', headingPlants: "Iran's Major Steel Plants",
allPlants: 'View All →', capacity: 'Annual capacity',
},
}
export default function SteelNewsletterSection() {
const { lang } = useLang()
const t = T[lang]
const [email, setEmail] = useState('')
const [done, setDone] = useState(false)
return (
<section dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper-2)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 64 }}>
{/* Newsletter issues */}
<div style={{ marginBottom: 64 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.overline}</div>
<h2 style={{ fontSize: 'clamp(20px,2.5vw,28px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>{t.heading}</h2>
</div>
<a href="#" style={{ fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>{t.archive}</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px,1fr))', gap: 1, background: 'var(--rule-thin)', marginBottom: 24 }}>
{ISSUES[lang].map((issue, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.35, delay: i * 0.07 }}
style={{ background: 'var(--paper)', padding: '22px 20px', cursor: 'pointer', display: 'flex', gap: 16, alignItems: 'flex-start' }}
>
<div style={{ fontSize: 36, fontWeight: 900, color: 'var(--red)', letterSpacing: '-2px', lineHeight: 1, flexShrink: 0, direction: 'ltr', fontVariantNumeric: 'tabular-nums' }}>
{issue.num}
</div>
<div>
<div style={{ fontSize: 9, fontWeight: 700, color: 'var(--ink-4)', letterSpacing: '1.5px', marginBottom: 6, textTransform: 'uppercase' }}>{t.issueLabel}</div>
<h3 style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.5, marginBottom: 8 }}>{issue.title}</h3>
<div style={{ fontSize: 10, color: 'var(--ink-5)' }}>{issue.date}</div>
</div>
</motion.div>
))}
</div>
{/* Subscribe bar */}
<div style={{ display: 'flex', maxWidth: 480, border: '1px solid var(--ink)' }}>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder={t.placeholder}
disabled={done}
style={{ flex: 1, border: 'none', padding: '11px 14px', fontSize: 13, fontFamily: 'Vazir, sans-serif', background: 'var(--paper)', color: 'var(--ink)', outline: 'none', direction: lang === 'fa' ? 'rtl' : 'ltr' }}
/>
<button
onClick={() => { if (email) setDone(true) }}
disabled={done}
style={{ background: done ? 'var(--ink)' : 'var(--red)', color: 'var(--paper)', border: 'none', padding: '11px 20px', fontSize: 12, fontWeight: 700, fontFamily: 'Vazir, sans-serif', cursor: done ? 'default' : 'pointer', whiteSpace: 'nowrap', flexShrink: 0 }}
>
{done ? t.done : t.subscribe}
</button>
</div>
</div>
{/* Major plants */}
<div>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>{t.overlinePlants}</div>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<h2 style={{ fontSize: 'clamp(20px,2.5vw,28px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>{t.headingPlants}</h2>
<a href="#" style={{ fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>{t.allPlants}</a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(210px,1fr))', gap: 1, background: 'var(--rule-thin)' }}>
{FACTORIES[lang].map((f, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.35, delay: i * 0.06 }}
style={{ background: 'var(--paper)', padding: '20px 18px', position: 'relative' }}
>
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 2, background: 'var(--ink)' }} />
<div style={{ fontSize: 14, fontWeight: 800, color: 'var(--ink)', marginBottom: 8, lineHeight: 1.3 }}>{f.name}</div>
<div style={{ fontSize: 11, color: 'var(--ink-4)', marginBottom: 2 }}>{f.location} · {f.type}</div>
<div style={{ fontSize: 20, fontWeight: 900, color: 'var(--red)', letterSpacing: '-0.5px', marginTop: 10 }}>{f.capacity}</div>
<div style={{ fontSize: 9, color: 'var(--ink-5)', fontWeight: 600, marginTop: 2 }}>{t.capacity}</div>
</motion.div>
))}
</div>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,109 @@
import { motion } from 'framer-motion'
const REPORTS = [
{
year: '۱۴۰۲',
title: 'گزارش پایداری فولاد مبارکه',
tags: ['کاهش کربن', 'مصرف آب', 'ایمنی شغلی'],
summary: 'کاهش ۱۸٪ انتشار CO₂ نسبت به سال پایه، بازیافت ۹۴٪ پساب صنعتی',
},
{
year: '۱۴۰۲',
title: 'گزارش مسئولیت اجتماعی ذوب‌آهن اصفهان',
tags: ['جامعه محلی', 'آموزش', 'محیط زیست'],
summary: 'سرمایه‌گذاری ۲۴۰ میلیارد تومانی در پروژه‌های اجتماعی منطقه',
},
{
year: '۱۴۰۱',
title: 'استراتژی خنثی‌سازی کربن صنعت فولاد ایران',
tags: ['ESG', 'هیدروژن سبز', 'نقشه راه'],
summary: 'نقشه راه کاهش انتشار تا ۴۵٪ تا سال ۱۴۱۰',
},
]
const MSTID_STATS = [
{ label: 'شاخص پایش‌شده', value: '۵۰۰+' },
{ label: 'کارخانه عضو', value: '۴۸' },
{ label: 'به‌روزرسانی داده', value: 'روزانه' },
]
export default function SustainabilitySection() {
return (
<section dir="rtl" style={{ background: 'var(--paper)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 64 }}>
{/* گزارش‌های پایداری */}
<div style={{ marginBottom: 56 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 24, flexWrap: 'wrap', gap: 12 }}>
<div>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)', marginBottom: 6 }}>SUSTAINABILITY</div>
<h2 style={{ fontSize: 'clamp(20px,2.5vw,28px)', fontWeight: 900, color: 'var(--ink)', letterSpacing: '-0.5px' }}>گزارشهای پایداری</h2>
</div>
<a href="#" style={{ fontSize: 12, fontWeight: 700, color: 'var(--red)', textDecoration: 'none' }}>همه گزارشها </a>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(270px,1fr))', gap: 1, background: 'var(--rule-thin)' }}>
{REPORTS.map((r, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.07 }}
style={{ background: 'var(--paper)', padding: '24px 22px', position: 'relative', cursor: 'pointer' }}
>
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 3, background: '#2d6a4f' }} />
<div style={{ fontSize: 9, fontWeight: 700, color: '#2d6a4f', letterSpacing: '1.5px', marginBottom: 10, textTransform: 'uppercase' }}>
گزارش پایداری · {r.year}
</div>
<h3 style={{ fontSize: 14, fontWeight: 800, color: 'var(--ink)', lineHeight: 1.45, marginBottom: 10 }}>{r.title}</h3>
<p style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.65, marginBottom: 14 }}>{r.summary}</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{r.tags.map(tag => (
<span key={tag} style={{ fontSize: 9, fontWeight: 700, background: 'rgba(45,106,79,0.1)', color: '#2d6a4f', padding: '3px 8px' }}>
{tag}
</span>
))}
</div>
</motion.div>
))}
</div>
</div>
{/* MSTid.com banner */}
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
style={{ background: 'var(--ink)', color: 'var(--paper)', padding: '36px 32px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 24 }}
>
<div>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'rgba(244,239,231,0.4)', marginBottom: 8 }}>POWERED BY</div>
<div style={{ fontSize: 28, fontWeight: 900, letterSpacing: '-1px', color: 'var(--paper)', marginBottom: 8, direction: 'ltr' }}>MSTid.com</div>
<div style={{ fontSize: 13, color: 'rgba(244,239,231,0.6)', lineHeight: 1.65, maxWidth: 420 }}>
مرجع جامع آمار و اطلاعات صنعت فولاد دادههای لحظهای از کارخانهها، بازارها و شاخصهای اقتصادی
</div>
</div>
<div style={{ display: 'flex', gap: 28, flexWrap: 'wrap' }}>
{MSTID_STATS.map(s => (
<div key={s.label} style={{ textAlign: 'center' }}>
<div style={{ fontSize: 28, fontWeight: 900, color: 'var(--red)', letterSpacing: '-1px', lineHeight: 1 }}>{s.value}</div>
<div style={{ fontSize: 10, color: 'rgba(244,239,231,0.5)', marginTop: 4, fontWeight: 600 }}>{s.label}</div>
</div>
))}
</div>
<a
href="https://mstid.com"
target="_blank"
rel="noopener noreferrer"
style={{ background: 'var(--red)', color: 'var(--paper)', padding: '12px 28px', fontSize: 13, fontWeight: 700, textDecoration: 'none', whiteSpace: 'nowrap', display: 'inline-block', flexShrink: 0 }}
>
ورود به سامانه
</a>
</motion.div>
</div>
</section>
)
}

View File

@ -0,0 +1,144 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { useLang } from '@/context/LangContext'
const PLANS = {
fa: [
{
id: 'basic', highlight: false,
title: 'پایه', subtitle: 'دسترسی به گزارش‌های عمومی',
price: '۲,۵۰۰,۰۰۰', period: 'تومان / سال',
features: ['دسترسی به گزارش‌های رایگان', 'خبرنامه ماهانه', 'دعوت به رویدادهای عمومی'],
cta: 'شروع رایگان', selected: '✓ انتخاب شد', recommended: 'پیشنهادی',
},
{
id: 'pro', highlight: true,
title: 'سازمانی', subtitle: 'مناسب شرکت‌های فولادی',
price: '۱۸,۰۰۰,۰۰۰', period: 'تومان / سال',
features: ['دسترسی کامل به نبض صنعت', 'تمام گزارش‌های تحلیلی', 'Flash Reports هفتگی', 'پشتیبانی اختصاصی', 'تا ۵ کاربر'],
cta: 'شروع عضویت', selected: '✓ انتخاب شد', recommended: 'پیشنهادی',
},
{
id: 'enterprise', highlight: false,
title: 'ویژه', subtitle: 'برای سازمان‌های بزرگ',
price: 'تماس بگیرید', period: '',
features: ['همه امکانات سازمانی', 'کاربران نامحدود', 'API داده', 'گزارش‌های سفارشی', 'نشست‌های تحلیلی اختصاصی'],
cta: 'تماس با ما', selected: '✓ انتخاب شد', recommended: 'پیشنهادی',
},
],
en: [
{
id: 'basic', highlight: false,
title: 'Basic', subtitle: 'Access to public reports',
price: '2,500,000', period: 'IRR / year',
features: ['Access to free reports', 'Monthly newsletter', 'Invitation to public events'],
cta: 'Get Started', selected: '✓ Selected', recommended: 'Recommended',
},
{
id: 'pro', highlight: true,
title: 'Professional', subtitle: 'For steel companies',
price: '18,000,000', period: 'IRR / year',
features: ['Full access to Industry Pulse', 'All analytical reports', 'Weekly Flash Reports', 'Dedicated support', 'Up to 5 users'],
cta: 'Start Membership', selected: '✓ Selected', recommended: 'Recommended',
},
{
id: 'enterprise', highlight: false,
title: 'Enterprise', subtitle: 'For large organizations',
price: 'Contact Us', period: '',
features: ['All Professional features', 'Unlimited users', 'Data API', 'Custom reports', 'Dedicated analytical sessions'],
cta: 'Contact Us', selected: '✓ Selected', recommended: 'Recommended',
},
],
}
const T = {
fa: {
overline: 'MEMBERSHIP', heading: 'عضویت',
sub: 'به جامعه تخصصی اندیشکده فولاد آینده بپیوندید و به داده‌ها و تحلیل‌های اختصاصی دسترسی داشته باشید',
contactHeading: 'تکمیل فرآیند عضویت',
contactSub: 'برای پرداخت و فعال‌سازی حساب، با تیم ما تماس بگیرید یا ایمیل ارسال کنید.',
},
en: {
overline: 'MEMBERSHIP', heading: 'Membership',
sub: 'Join the specialized community of the Future Steel Policy Institute and gain access to exclusive data and analysis',
contactHeading: 'Complete Membership Process',
contactSub: 'To complete payment and activate your account, contact our team or send an email.',
},
}
export default function Membership() {
const { lang } = useLang()
const t = T[lang]
const plans = PLANS[lang]
const [selected, setSelected] = useState<string | null>(null)
return (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '80vh' }}>
<div style={{ borderBottom: '3px solid var(--ink)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 36, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>{t.overline}</span>
</div>
<h1 style={{ fontSize: 'clamp(28px,4vw,52px)', fontWeight: 900, letterSpacing: '-1.5px', color: 'var(--ink)', marginBottom: 12 }}>{t.heading}</h1>
<p style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.7, maxWidth: 560 }}>{t.sub}</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 56, paddingBottom: 72 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 1, background: 'var(--rule-thin)', marginBottom: 56 }}>
{plans.map((plan, i) => (
<motion.div
key={plan.id}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.08 }}
style={{ background: plan.highlight ? 'var(--ink)' : 'var(--paper)', color: plan.highlight ? 'var(--paper)' : 'var(--ink)', padding: '36px 28px', position: 'relative', display: 'flex', flexDirection: 'column' }}
>
{plan.highlight && <div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 4, background: 'var(--red)' }} />}
{plan.highlight && (
<div style={{ position: 'absolute', top: 16, left: 20, fontSize: 9, fontWeight: 700, letterSpacing: '2px', color: 'var(--red)', textTransform: 'uppercase' }}>
{plan.recommended}
</div>
)}
<div style={{ fontSize: 11, fontWeight: 700, color: plan.highlight ? 'rgba(244,239,231,0.5)' : 'var(--ink-4)', letterSpacing: '1.5px', textTransform: 'uppercase', marginBottom: 8 }}>{plan.title}</div>
<div style={{ fontSize: 13, color: plan.highlight ? 'rgba(244,239,231,0.6)' : 'var(--ink-3)', marginBottom: 24 }}>{plan.subtitle}</div>
<div style={{ marginBottom: 28 }}>
<span style={{ fontSize: plan.price === 'تماس بگیرید' || plan.price === 'Contact Us' ? 18 : 24, fontWeight: 900, letterSpacing: '-0.5px' }}>{plan.price}</span>
{plan.period && <span style={{ fontSize: 11, color: plan.highlight ? 'rgba(244,239,231,0.5)' : 'var(--ink-4)', marginRight: lang === 'fa' ? 6 : 0, marginLeft: lang === 'en' ? 6 : 0 }}>{plan.period}</span>}
</div>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 28px', flex: 1, display: 'flex', flexDirection: 'column', gap: 10 }}>
{plan.features.map(f => (
<li key={f} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: plan.highlight ? 'rgba(244,239,231,0.8)' : 'var(--ink-3)' }}>
<span style={{ color: 'var(--red)', fontWeight: 900, fontSize: 13, flexShrink: 0 }}></span>
{f}
</li>
))}
</ul>
<button
onClick={() => setSelected(plan.id)}
style={{ background: selected === plan.id ? 'var(--red)' : plan.highlight ? 'var(--paper)' : 'var(--ink)', color: plan.highlight && selected !== plan.id ? 'var(--ink)' : 'var(--paper)', border: 'none', padding: '12px 0', fontSize: 13, fontWeight: 700, fontFamily: 'Vazir, sans-serif', cursor: 'pointer', width: '100%', transition: 'background 150ms', letterSpacing: '0.3px' }}
>
{selected === plan.id ? plan.selected : plan.cta}
</button>
</motion.div>
))}
</div>
{selected && selected !== 'enterprise' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
style={{ border: '2px solid var(--ink)', padding: '32px 28px', maxWidth: 480, position: 'relative' }}
>
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 4, background: 'var(--red)' }} />
<h3 style={{ fontSize: 16, fontWeight: 800, color: 'var(--ink)', marginBottom: 10 }}>{t.contactHeading}</h3>
<p style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.75, marginBottom: 20 }}>{t.contactSub}</p>
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--ink)' }}>info@iransteel.ir</div>
</motion.div>
)}
</div>
</div>
)
}

126
src/pages/Pulse/Pulse.tsx Normal file
View File

@ -0,0 +1,126 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { useLang } from '@/context/LangContext'
const STATS = {
fa: [
{ label: 'تولید فولاد خام', value: '۳۱.۲', unit: 'میلیون تن', delta: '+۴.۱٪' },
{ label: 'صادرات فولاد', value: '۸.۷', unit: 'میلیون تن', delta: '+۱.۸٪' },
{ label: 'واردات سنگ‌آهن', value: '۱۲.۴', unit: 'میلیون تن', delta: '-۲.۳٪' },
{ label: 'ظرفیت نصب‌شده', value: '۳۸', unit: 'میلیون تن', delta: '—' },
],
en: [
{ label: 'Crude Steel Production', value: '31.2', unit: 'Million tons', delta: '+4.1%' },
{ label: 'Steel Exports', value: '8.7', unit: 'Million tons', delta: '+1.8%' },
{ label: 'Iron Ore Imports', value: '12.4', unit: 'Million tons', delta: '-2.3%' },
{ label: 'Installed Capacity', value: '38', unit: 'Million tons', delta: '—' },
],
}
const T = {
fa: {
overline: 'INDUSTRY PULSE', heading: 'نبض صنعت',
sub: 'سامانه جامع آمار و اطلاعات صنعت فولاد ایران — داده‌های لحظه‌ای تولید، صادرات، واردات و قیمت‌ها',
statsLabel: 'آمار کلیدی صنعت · ۱۴۰۳',
gateAccess: 'دسترسی اعضا',
gateHeading: 'دسترسی کامل به سامانه نبض صنعت',
gateSub: 'اعضای اندیشکده به پایگاه داده جامع فولاد ایران شامل آمار لحظه‌ای تولید، صادرات، واردات، قیمت‌های داخلی و بین‌المللی و ۵۰۰+ شاخص صنعتی دسترسی دارند.',
features: [
'داده‌های لحظه‌ای تولید کارخانه‌های بزرگ',
'آمار صادرات تفکیک‌شده بر اساس کشور مقصد',
'نمودارهای تعاملی و قابل دانلود',
'گزارش‌های تحلیلی ماهانه اختصاصی',
],
cta: 'درخواست عضویت سازمانی ←',
ctaDone: '✓ درخواست شما ثبت شد. تیم ما ظرف ۴۸ ساعت با شما تماس می‌گیرد.',
},
en: {
overline: 'INDUSTRY PULSE', heading: 'Industry Pulse',
sub: 'Comprehensive statistics and data platform for Iran\'s steel industry — live production, export, import, and price data',
statsLabel: 'KEY INDUSTRY STATS · 1403',
gateAccess: 'MEMBERS ONLY',
gateHeading: 'Full Access to Industry Pulse',
gateSub: 'Institute members have access to Iran\'s comprehensive steel database including live production stats, export/import data, domestic and international prices, and 500+ industrial indicators.',
features: [
'Real-time production data from major plants',
'Export statistics broken down by destination country',
'Interactive downloadable charts',
'Exclusive monthly analytical reports',
],
cta: 'Request Organizational Membership →',
ctaDone: '✓ Your request has been received. Our team will contact you within 48 hours.',
},
}
export default function Pulse() {
const { lang } = useLang()
const t = T[lang]
const stats = STATS[lang]
const [requested, setRequested] = useState(false)
return (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '80vh' }}>
<div style={{ borderBottom: '3px solid var(--ink)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 36, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>{t.overline}</span>
</div>
<h1 style={{ fontSize: 'clamp(28px,4vw,52px)', fontWeight: 900, letterSpacing: '-1.5px', color: 'var(--ink)', marginBottom: 12 }}>{t.heading}</h1>
<p style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.7, maxWidth: 600 }}>{t.sub}</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 64 }}>
<div style={{ marginBottom: 48 }}>
<div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '2px', color: 'var(--ink-4)', textTransform: 'uppercase', marginBottom: 20 }}>
{t.statsLabel}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 1, background: 'var(--rule-thin)' }}>
{stats.map((s, i) => (
<div key={i} style={{ background: 'var(--paper)', padding: '24px 20px', filter: i >= 2 ? 'blur(5px)' : 'none', userSelect: i >= 2 ? 'none' : 'auto', pointerEvents: i >= 2 ? 'none' : 'auto' }}>
<div style={{ fontSize: 32, fontWeight: 900, letterSpacing: '-1px', color: 'var(--ink)', lineHeight: 1, marginBottom: 6 }}>{s.value}</div>
<div style={{ fontSize: 11, color: 'var(--ink-5)', marginBottom: 4 }}>{s.unit}</div>
<div style={{ fontSize: 10, fontWeight: 700, color: 'var(--ink-3)' }}>{s.label}</div>
<div style={{ fontSize: 10, fontWeight: 700, color: s.delta.startsWith('+') ? '#2d6a4f' : s.delta.startsWith('-') ? 'var(--red)' : 'var(--ink-5)', marginTop: 6 }}>{s.delta}</div>
</div>
))}
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
style={{ border: '2px solid var(--ink)', padding: '40px 36px', maxWidth: 600, position: 'relative' }}
>
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 4, background: 'var(--red)' }} />
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '2.5px', textTransform: 'uppercase', color: 'var(--red)', marginBottom: 16 }}>{t.gateAccess}</div>
<h2 style={{ fontSize: 20, fontWeight: 900, color: 'var(--ink)', marginBottom: 12, lineHeight: 1.3 }}>{t.gateHeading}</h2>
<p style={{ fontSize: 13, color: 'var(--ink-3)', lineHeight: 1.75, marginBottom: 24 }}>{t.gateSub}</p>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 28px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{t.features.map(item => (
<li key={item} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: 'var(--ink)' }}>
<span style={{ color: 'var(--red)', fontWeight: 900, fontSize: 14 }}></span>
{item}
</li>
))}
</ul>
{!requested ? (
<button
onClick={() => setRequested(true)}
style={{ background: 'var(--ink)', color: 'var(--paper)', border: 'none', padding: '13px 32px', fontSize: 13, fontWeight: 700, fontFamily: 'Vazir, sans-serif', cursor: 'pointer', letterSpacing: '0.3px', transition: 'background 150ms' }}
>
{t.cta}
</button>
) : (
<div style={{ background: 'rgba(26,23,18,0.05)', padding: '14px 20px', fontSize: 13, color: 'var(--ink)', fontWeight: 600 }}>
{t.ctaDone}
</div>
)}
</motion.div>
</div>
</div>
)
}

82
src/pages/Radar/Radar.tsx Normal file
View File

@ -0,0 +1,82 @@
import { motion } from 'framer-motion'
import { useLang } from '@/context/LangContext'
const SIGNALS = {
fa: [
{ tag: 'چین', title: 'صادرات فولاد چین به بالاترین رکورد ۸ ساله رسید', date: 'بهمن ۱۴۰۳', level: 'critical' },
{ tag: 'اروپا', title: 'CBAM اروپا از فروردین ۱۴۰۴ اجرایی می‌شود', date: 'دی ۱۴۰۳', level: 'high' },
{ tag: 'هند', title: 'هند ظرفیت تولید فولاد خود را تا ۲۰۳۰ دو برابر می‌کند', date: 'آذر ۱۴۰۳', level: 'medium' },
{ tag: 'سنگ‌آهن', title: 'قیمت سنگ‌آهن دالیان ۱۸٪ جهش کرد', date: 'بهمن ۱۴۰۳', level: 'high' },
{ tag: 'انرژی', title: 'هزینه برق صنایع فولاد ۳۲٪ افزایش یافت', date: 'دی ۱۴۰۳', level: 'high' },
{ tag: 'فناوری', title: 'اولین کارخانه فولاد هیدروژنی اروپا وارد مرحله تولید شد', date: 'آذر ۱۴۰۳', level: 'medium' },
],
en: [
{ tag: 'China', title: "China's Steel Exports Hit an 8-Year Record High", date: 'Feb 2025', level: 'critical' },
{ tag: 'Europe', title: "Europe's CBAM Mechanism Takes Effect from Spring 2025", date: 'Jan 2025', level: 'high' },
{ tag: 'India', title: 'India to Double Steel Production Capacity by 2030', date: 'Dec 2024', level: 'medium' },
{ tag: 'Iron Ore', title: 'Dalian Iron Ore Price Surges 18%', date: 'Feb 2025', level: 'high' },
{ tag: 'Energy', title: 'Steel Industry Electricity Costs Rise 32%', date: 'Jan 2025', level: 'high' },
{ tag: 'Technology', title: "Europe's First Hydrogen Steel Plant Enters Production", date: 'Dec 2024', level: 'medium' },
],
}
const LEVEL_COLOR: Record<string, string> = {
critical: '#7f1d1d',
high: 'var(--red)',
medium: '#b45309',
}
const LEVEL_LABEL = {
fa: { critical: 'بحرانی', high: 'بالا', medium: 'متوسط' },
en: { critical: 'Critical', high: 'High', medium: 'Medium' },
}
const T = {
fa: { overline: 'FUTURE RADAR', heading: 'رادار آینده', sub: 'رصد سیگنال‌های جهانی و منطقه‌ای با اثر مستقیم بر صنعت فولاد ایران' },
en: { overline: 'FUTURE RADAR', heading: 'Future Radar', sub: 'Monitoring global and regional signals with direct impact on Iran\'s steel industry' },
}
export default function Radar() {
const { lang } = useLang()
const t = T[lang]
const signals = SIGNALS[lang]
const levelLabel = LEVEL_LABEL[lang]
return (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '80vh' }}>
<div style={{ borderBottom: '3px solid var(--ink)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 36, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>{t.overline}</span>
</div>
<h1 style={{ fontSize: 'clamp(28px,4vw,52px)', fontWeight: 900, letterSpacing: '-1.5px', color: 'var(--ink)', marginBottom: 12 }}>{t.heading}</h1>
<p style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.7, maxWidth: 560 }}>{t.sub}</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 64 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 1, background: 'var(--rule-thin)' }}>
{signals.map((s, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.06 }}
style={{ background: 'var(--paper)', padding: '28px 24px', position: 'relative' }}
>
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 3, background: LEVEL_COLOR[s.level] }} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
<span style={{ fontSize: 9, fontWeight: 700, letterSpacing: '1.5px', background: 'var(--ink)', color: 'var(--paper)', padding: '3px 8px' }}>{s.tag}</span>
<span style={{ fontSize: 9, fontWeight: 700, color: LEVEL_COLOR[s.level], letterSpacing: '1px' }}>{levelLabel[s.level as keyof typeof levelLabel]}</span>
</div>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--ink)', lineHeight: 1.55, marginBottom: 16 }}>{s.title}</h3>
<div style={{ fontSize: 10, color: 'var(--ink-5)', fontWeight: 600 }}>{s.date}</div>
</motion.div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,68 @@
import { motion } from 'framer-motion'
import { useLang } from '@/context/LangContext'
const TECHS = {
fa: [
{ num: '۰۱', title: 'فولاد هیدروژنی (DRI-H₂)', tag: 'انرژی پاک', summary: 'جایگزینی کک با هیدروژن سبز در کوره‌های احیای مستقیم — مسیر صنعت فولاد به سمت خنثی‌سازی کربن' },
{ num: '۰۲', title: 'دیجیتال‌سازی و AI در تولید', tag: 'هوش مصنوعی', summary: 'کاربرد یادگیری ماشین در بهینه‌سازی فرآیند ذوب، پیش‌بینی نقص کیفی و کاهش ضایعات' },
{ num: '۰۳', title: 'الکترولیز فولاد (MOE)', tag: 'فناوری نوین', summary: 'تولید فولاد از طریق الکترولیز اکسید آهن با برق تجدیدپذیر — فناوری نسل بعدی' },
{ num: '۰۴', title: 'بازیافت و اقتصاد چرخشی', tag: 'پایداری', summary: 'افزایش نرخ بازیافت ضایعات فولادی و کاهش وابستگی به سنگ‌آهن خام' },
{ num: '۰۵', title: 'کوره قوس الکتریکی نسل جدید EAF', tag: 'تجهیزات', summary: 'EAF با راندمان انرژی ۴۰٪ بالاتر از نسل فعلی — مناسب برای شبکه‌های ناپایدار' },
{ num: '۰۶', title: 'توأم‌سازی CCS با تولید فولاد', tag: 'محیط زیست', summary: 'ترکیب کربن‌گیری با فرآیندهای موجود — راه‌حل میان‌مدت برای کاهش انتشار' },
],
en: [
{ num: '01', title: 'Hydrogen Steel (DRI-H₂)', tag: 'Clean Energy', summary: 'Replacing coke with green hydrogen in direct reduction furnaces — the steel industry\'s path to carbon neutrality' },
{ num: '02', title: 'Digitalization & AI in Production', tag: 'Artificial Intelligence', summary: 'Machine learning applied to smelting optimization, quality defect prediction, and waste reduction' },
{ num: '03', title: 'Molten Oxide Electrolysis (MOE)', tag: 'Emerging Tech', summary: 'Producing steel via electrolysis of iron oxide using renewable electricity — next-generation technology' },
{ num: '04', title: 'Recycling & Circular Economy', tag: 'Sustainability', summary: 'Increasing steel scrap recycling rates and reducing dependence on raw iron ore' },
{ num: '05', title: 'New-Generation Electric Arc Furnace', tag: 'Equipment', summary: 'EAF with 40% higher energy efficiency than current generation — optimized for unstable power grids' },
{ num: '06', title: 'CCS Integration with Steel Production',tag: 'Environment', summary: 'Combining carbon capture with existing processes — a medium-term solution for emission reduction' },
],
}
const T = {
fa: { overline: 'TECH FRONTIER', heading: 'مرز فناوری', sub: 'فناوری‌های نوظهور در صنعت فولاد جهان و افق کاربرد آن‌ها در ایران' },
en: { overline: 'TECH FRONTIER', heading: 'Tech Frontier', sub: 'Emerging technologies in the global steel industry and their application horizon in Iran' },
}
export default function Technology() {
const { lang } = useLang()
const t = T[lang]
const techs = TECHS[lang]
return (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '80vh' }}>
<div style={{ borderBottom: '3px solid var(--ink)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 36, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>{t.overline}</span>
</div>
<h1 style={{ fontSize: 'clamp(28px,4vw,52px)', fontWeight: 900, letterSpacing: '-1.5px', color: 'var(--ink)', marginBottom: 12 }}>{t.heading}</h1>
<p style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.7, maxWidth: 560 }}>{t.sub}</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 64 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 1, background: 'var(--rule-thin)' }}>
{techs.map((tech, i) => (
<motion.div
key={i}
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.06 }}
style={{ background: 'var(--paper)', padding: '28px 24px', position: 'relative' }}
>
<div style={{ position: 'absolute', top: 0, right: 0, left: 0, height: 3, background: 'var(--ink)' }} />
<div style={{ fontSize: 48, fontWeight: 900, letterSpacing: '-3px', color: 'rgba(26,23,18,0.06)', lineHeight: 1, marginBottom: 10, direction: 'ltr' }}>{tech.num}</div>
<div style={{ fontSize: 9, fontWeight: 700, letterSpacing: '1.5px', color: 'var(--red)', marginBottom: 10, textTransform: 'uppercase' }}>{tech.tag}</div>
<h3 style={{ fontSize: 15, fontWeight: 800, color: 'var(--ink)', lineHeight: 1.45, marginBottom: 12 }}>{tech.title}</h3>
<p style={{ fontSize: 12, color: 'var(--ink-3)', lineHeight: 1.7 }}>{tech.summary}</p>
</motion.div>
))}
</div>
</div>
</div>
)
}