diff --git a/package-lock.json b/package-lock.json index 699cda7..aad4493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9594e4d..b0956b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/panel/db.js b/panel/db.js index 4e8b176..9d591b7 100644 --- a/panel/db.js +++ b/panel/db.js @@ -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 { diff --git a/panel/package-lock.json b/panel/package-lock.json index 9ccf4cb..87ca8b8 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -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", diff --git a/panel/package.json b/panel/package.json index 28de44c..366c895 100644 --- a/panel/package.json +++ b/panel/package.json @@ -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", diff --git a/panel/public/app.js b/panel/public/app.js index a2f40eb..4a460f6 100644 --- a/panel/public/app.js +++ b/panel/public/app.js @@ -71,21 +71,40 @@ function renderLogin(errorMsg = '') { }); } -async function renderList() { - root.innerHTML = ` +function topbarHtml(active, primaryLabel) { + return `
پنل اندیشکده فولاد
+
- +
+ `; +} +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', '+ مطلب جدید')}

مطالب

در حال بارگذاری…

`; - $('#logoutBtn').addEventListener('click', () => { clearToken(); renderLogin(); }); + wireTabs(); $('#newBtn').addEventListener('click', () => renderEditor(null)); try { @@ -290,5 +309,130 @@ function escapeHtml(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>'); } +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', '+ سیگنال جدید')} +
+

سیگنال‌های ریسک

+

در حال بارگذاری…

+
+ `; + wireTabs(); + $('#newBtn').addEventListener('click', () => renderRiskEditor(null)); + + try { + const items = await api('/api/risks?limit=200'); + if (!items.length) { + $('#list').innerHTML = `

هنوز سیگنالی ثبت نشده است.

`; + return; + } + const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || lvl; + $('#list').innerHTML = items.map(r => ` +
+
+
+ ${labelOf(r.level)} + ${r.name} + ${r.date ? `${r.date}` : ''} +
+

${r.quote}

+
+
+ + +
+
+ `).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 = `

خطا در بارگذاری سیگنال‌ها.

`; + } +} + +function renderRiskEditor(risk) { + const r = risk || { quote: '', name: '', date: '', level: 'high', sortOrder: 0 }; + const isEdit = !!risk; + + root.innerHTML = ` +
+
${isEdit ? 'ویرایش سیگنال ریسک' : 'سیگنال ریسک جدید'}
+
+ +
+
+
+
+
+ +
+ + + +
+ +
+
+ +
+
+
+ `; + + $('#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(); diff --git a/panel/public/style.css b/panel/public/style.css index 603932a..020278d 100644 --- a/panel/public/style.css +++ b/panel/public/style.css @@ -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; } diff --git a/panel/scraper.js b/panel/scraper.js new file mode 100644 index 0000000..b75bc9a --- /dev/null +++ b/panel/scraper.js @@ -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 for these tables (not the first ). + 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 '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 + // ("(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; } +} diff --git a/panel/server.js b/panel/server.js index a7ee5f7..b3d7267 100644 --- a/panel/server.js +++ b/panel/server.js @@ -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'); + } }); diff --git a/src/app/layout/Footer.tsx b/src/app/layout/Footer.tsx index cbcbeeb..1463ef4 100644 --- a/src/app/layout/Footer.tsx +++ b/src/app/layout/Footer.tsx @@ -28,58 +28,31 @@ function SocialIcon({ label }: { label: string }) { function FooterLink({ href, label }: { href: string; label: string }) { const [hovered, setHovered] = useState(false) return ( -
  • - setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - fontSize: 13, - color: hovered ? '#fff' : 'rgba(255,255,255,0.55)', - textDecoration: 'none', - cursor: 'pointer', - transition: 'color 150ms', - display: 'inline-block', - lineHeight: 1.4, - }} - > - {label} - -
  • + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + fontSize: 13, + color: hovered ? '#fff' : 'rgba(255,255,255,0.55)', + textDecoration: 'none', + cursor: 'pointer', + transition: 'color 150ms', + display: 'inline-block', + lineHeight: 1.4, + whiteSpace: 'nowrap', + }} + > + {label} + ) } -const COLUMNS = [ - { - heading: 'گزارش‌ها', - links: [ - { label: 'گزارش‌های فصلی', href: '#' }, - { label: 'Flash Reports', href: '#' }, - { label: 'گزارش‌های ریسک', href: '#' }, - { label: 'گزارش رایگان', href: '#' }, - { label: 'آرشیو کامل', href: '#' }, - ], - }, - { - heading: 'اندیشکده', - links: [ - { label: 'درباره ما', href: '#' }, - { label: 'تیم تحریریه', href: '#' }, - { label: 'اشتراک سازمانی', href: '#' }, - { label: 'همکاری با ما', href: '#' }, - { label: 'تماس', href: '#' }, - ], - }, - { - heading: 'منابع', - links: [ - { label: 'داشبورد بازار', href: '#' }, - { label: 'رویدادها', href: '#' }, - { label: 'اسکنر جهانی', href: '#' }, - { label: 'خبرنامه', href: '#' }, - { label: 'درگاه پرداخت', href: '#' }, - ], - }, +const FOOTER_LINKS = [ + { 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 */} -
    -
    - {/* Brand */} -
    -
    + {/* TOP SECTION */} +
    +
    + + {/* Brand + description */} +
    +
    اندیشکده فولاد آینده
    -
    +
    FUTURE STEEL POLICY INSTITUTE
    -

    - مرکز مستقل تحلیل راهبردی و سیاست‌گذاری صنعت فولاد و فلزات کشور +

    + مرکز مستقل تحلیل راهبردی و سیاست‌گذاری صنعت فولاد و فلزات کشور — زیر نظر شرکت فولاد مبارکه

    -
    -
    تهران · خیابان ولیعصر
    -
    info@iransteel.ir
    -
    ۰۲۱–۸۸۱۲۳۴۵۶
    -
    -
    +
    @@ -133,67 +84,24 @@ export function Footer() {
    - {/* Dividers + link columns */} - {COLUMNS.map((col, i) => ( - <> -
    {/* BOTTOM ROW */} -
    +
    - © ۱۴۰۳ اندیشکده فولاد آینده — تمام حقوق محفوظ است -
    - {['حریم خصوصی', 'شرایط استفاده', 'درگاه پرداخت', 'نقشه سایت'].map(l => ( - - {l} - - ))} +
    تمامی حقوق این وب سایت محفوظ و متعلق به شرکت فولاد مبارکه است.
    +
    + با افتخار قدرت گرفته از دیدوان؛ چشم همیشه باز مدیران
    diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx index c112658..a80fce2 100644 --- a/src/app/layout/Header.tsx +++ b/src/app/layout/Header.tsx @@ -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(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 */} - شنبه ۲۱ بهمن ۱۴۰۳ · دوره دوازدهم + {lang === 'fa' ? 'شنبه ۲۱ بهمن ۱۴۰۳ · دوره دوازدهم' : 'Sat 10 Feb 2025 · Vol. XII'} - {/* Left: meta links + login */} + {/* Left: meta links + login + lang toggle */}
    @@ -290,7 +304,7 @@ export default function Header() { }} > {NAV_ITEMS.map((item) => ( - + ))} )} @@ -467,7 +481,7 @@ export default function Header() { setMobileOpen(false)} /> ))} @@ -484,10 +498,10 @@ export default function Header() { }} > - اشتراک سازمانی + {lang === 'fa' ? 'اشتراک سازمانی' : 'Membership'} - ورود + {lang === 'fa' ? 'ورود' : 'Login'} diff --git a/src/app/layout/MarketStrip.tsx b/src/app/layout/MarketStrip.tsx index cf65c9e..dac4a19 100644 --- a/src/app/layout/MarketStrip.tsx +++ b/src/app/layout/MarketStrip.tsx @@ -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 }).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(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 (
    -
    - -
    - -
    -
    -
    + +
    +
    + +
    + +
    +
    +
    +
    ) } diff --git a/src/app/router.tsx b/src/app/router.tsx index 9f07893..2570ce3 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -11,23 +11,31 @@ 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([ { path: '/', element: , children: [ - { index: true, element: }, - { path: 'reports', element: }, + { index: true, element: }, + { path: 'reports', element: }, { path: 'reports/:id', element: }, - { path: 'market', element: }, - { path: 'subscribe', element: }, - { path: 'events', element: }, - { path: 'team', element: }, - { path: 'archive', element: }, - { path: 'special', element: }, - { path: 'risks', element: }, - { path: 'scanner', element: }, + { path: 'market', element: }, + { path: 'subscribe', element: }, + { path: 'events', element: }, + { path: 'team', element: }, + { path: 'archive', element: }, + { path: 'special', element: }, + { path: 'risks', element: }, + { path: 'scanner', element: }, + { path: 'radar', element: }, + { path: 'technology', element: }, + { path: 'pulse', element: }, + { path: 'membership', element: }, ], }, ]) diff --git a/src/components/ui/bento-grid.tsx b/src/components/ui/bento-grid.tsx index 3f21e97..3f4a130 100644 --- a/src/components/ui/bento-grid.tsx +++ b/src/components/ui/bento-grid.tsx @@ -57,7 +57,7 @@ export function BentoGridItem({
    {description} diff --git a/src/context/LangContext.tsx b/src/context/LangContext.tsx new file mode 100644 index 0000000..a430a18 --- /dev/null +++ b/src/context/LangContext.tsx @@ -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({ lang: 'fa', toggle: () => {} }) + +export function LangProvider({ children }: { children: ReactNode }) { + const [lang, setLang] = useState('fa') + const toggle = () => setLang(l => (l === 'fa' ? 'en' : 'fa')) + return {children} +} + +export function useLang() { + return useContext(LangContext) +} diff --git a/src/pages/Events/Events.tsx b/src/pages/Events/Events.tsx index 6ad07f2..b2daab5 100644 --- a/src/pages/Events/Events.tsx +++ b/src/pages/Events/Events.tsx @@ -1,200 +1,88 @@ 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 = { - conference: 'کنگره', - exhibition: 'نمایشگاه', - seminar: 'سمینار', - international: 'جهانی', -} +type FilterValue = 'all' | EventType const TYPE_BORDER_COLORS: Record = { - conference: 'var(--ink)', - exhibition: '#6b4a00', - seminar: '#1a4a2a', + conference: 'var(--ink)', + exhibition: '#6b4a00', + seminar: '#1a4a2a', 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: '1403–1404', 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 (
    setHovered(true)} onMouseLeave={() => setHovered(false)} > - {/* date + type header */}
    - {/* date block */} -
    - - {event.date.toLocaleString('fa-IR')} +
    + + {event.date.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')} - - {event.month} {event.year.toLocaleString('fa-IR')} + + {event.month} {event.year.toLocaleString(lang === 'fa' ? 'fa-IR' : 'en-US')}
    - - {/* type badge + title area top */} -
    - - {TYPE_LABELS[event.type]} +
    + + {typeLabels[event.type]} - -

    - {event.title} -

    +

    {event.title}

    - {/* body */}
    - {/* location */}
    - - - {event.location}، {event.city} - + + {event.location}، {event.city}
    - - {/* description */} -

    - {event.description} -

    +

    {event.description}

    - {/* footer */} -
    +
    {event.registrationOpen ? ( - - ثبت‌نام - + + {lang === 'fa' ? 'ثبت‌نام' : 'Register'} ) : ( - - به‌زودی + + {lang === 'fa' ? 'به‌زودی' : 'Coming Soon'} )}
    @@ -202,120 +90,39 @@ function EventCard({ event }: { event: (typeof events)[0] }) { ) } -/* ─── main component ─────────────────────────────────────── */ export default function Events() { - const [activeFilter, setActiveFilter] = useState('همه') + const { lang } = useLang() + const t = T[lang] + const filters = FILTERS[lang] + const [activeFilter, setActiveFilter] = useState('all') - const filtered = activeFilter === 'همه' - ? events - : events.filter((e) => e.type === activeFilter) + const filtered = activeFilter === 'all' ? events : events.filter(e => e.type === activeFilter) return ( -
    - {/* ─── Page title banner ─────────────────────────────── */} -
    -
    -

    - رویدادهای پیش‌رو -

    +
    +
    +
    +

    {t.heading}

    -
    - -
    - - ۱۴۰۳–۱۴۰۴ - +
    + {t.year}
    - {/* ─── Filter tabs ───────────────────────────────────── */} -
    - {FILTERS.map((f) => { +
    + {filters.map(f => { const isActive = activeFilter === f.value return ( @@ -323,45 +130,18 @@ export default function Events() { })}
    - {/* ─── Events grid ───────────────────────────────────── */} -
    - {filtered.map((event) => ( - - ))} - {/* fill odd trailing cell */} - {filtered.length % 2 !== 0 && ( -
    - )} +
    + {filtered.map(event => )} + {filtered.length % 2 !== 0 &&
    }
    {filtered.length === 0 && ( -
    -

    رویدادی در این دسته‌بندی یافت نشد

    +
    +

    {t.empty}

    )} - {/* responsive */} - +
    ) } diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index a4e6034..10433fe 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -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 ( -
    -
    - {/* Left (visual end in RTL): chapter index */} -
    - +
    +
    +
    +
    {n} - -
    - - CHAPTER - - - بخش {n} -
    +
    - - {/* Right (visual start RTL): thin rule fills */} -
    ) @@ -108,48 +39,34 @@ function SectionLabel({ /* ─── Home page ──────────────────────────────────────── */ export default function Home() { + const { lang } = useLang() return ( -
    +
    {/* ── 00 · Hero ─────────────────────────────────── */} {/* ── Ticker ────────────────────────────────────── */} - {/* ── 01 · اسکنر جهانی · bg: paper ─────────────── */} - + {/* ── 01 · اسکنر جهانی ──────────────────────────── */} + - {/* ── 02 · تحلیل‌ها · bg: paper-2 ──────────────── */} - - + {/* ── 02 · خبرنامه فولاد ────────────────────────── */} + + - {/* ── 03 · اخبار · bg: paper ────────────────────── */} - - - - {/* ── 04 · رویدادها · bg: paper-2 ──────────────── */} - + {/* ── 03 · آخرین رویدادها ───────────────────────── */} + - {/* ── 05 · ریسک · bg: paper ─────────────────────── */} - + {/* ── 04 · گزارش کارخانه‌ها ─────────────────────── */} + + + + {/* ── 05 · نقشه ریسک ────────────────────────────── */} + - - {/* ── 06 · تیم · bg: paper-2 ────────────────────── */} - - - - {/* ── 07 · اشتراک · bg: paper ───────────────────── */} - - - - {/* ── 08 · همراهان · bg: paper-2 ────────────────── */} - - - - {/* ── خبرنامه (last, no label) ───────────────────── */} -
    ) } diff --git a/src/pages/Home/sections/EventsSection.tsx b/src/pages/Home/sections/EventsSection.tsx index 7203e50..353608c 100644 --- a/src/pages/Home/sections/EventsSection.tsx +++ b/src/pages/Home/sections/EventsSection.tsx @@ -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 = { - conference: 'کنگره', - exhibition: 'نمایشگاه', - seminar: 'سمینار', - international: 'جهانی', +const typeLabel: Record = { + 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 · 1403–1404', + 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 = { @@ -24,6 +44,8 @@ const typeTextColor: Record = { } 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() {
    - تقویم رویداد · ۱۴۰۳–۱۴۰۴ + {t.overline}
    -

    - رویدادهای پیش‌رو +

    + {t.heading}

    - همایش‌ها، نمایشگاه‌ها و نشست‌های تخصصی صنعت فولاد در ماه‌های آینده — برای حضور یا حمایت رسانه‌ای. + {t.sub}

    - تقویم کامل ← + {t.all}
    @@ -97,7 +119,7 @@ export default function EventsSection() { lineHeight: 1.4, }} > - {typeLabel[event.type]} + {typeLabel[event.type][lang]}
    @@ -110,11 +132,11 @@ export default function EventsSection() { {/* CTA */} {event.registrationOpen ? ( - ثبت‌نام ← + {t.register} ) : ( - به‌زودی + {t.soon} )}
    diff --git a/src/pages/Home/sections/FactoryBentoSection.tsx b/src/pages/Home/sections/FactoryBentoSection.tsx new file mode 100644 index 0000000..b379da7 --- /dev/null +++ b/src/pages/Home/sections/FactoryBentoSection.tsx @@ -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 ( +
    +
    + {report.publisherEn.toUpperCase()} +
    +
    + {lang === 'fa' ? report.publisher : report.publisherEn} +
    +
    + ) +} + +export default function FactoryBentoSection() { + const { lang } = useLang() + const t = T[lang] + + return ( +
    +
    +
    +
    +
    {t.overline}
    +

    {t.heading}

    +
    + {t.all} +
    + + + {REPORTS.map((report, i) => ( + } + 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' : ''} + /> + ))} + +
    +
    + ) +} diff --git a/src/pages/Home/sections/GlobalScannerSection.tsx b/src/pages/Home/sections/GlobalScannerSection.tsx index 2271bac..52fa1d1 100644 --- a/src/pages/Home/sections/GlobalScannerSection.tsx +++ b/src/pages/Home/sections/GlobalScannerSection.tsx @@ -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 (
    {/* Headline */}

    - تمام رویدادهای فولادی دنیا{' '} + {t.h2a}{' '} - را رصد می‌کنیم + {t.h2b}

    @@ -90,9 +116,7 @@ export default function GlobalScannerSection() { maxWidth: 600, margin: '0 auto', }}> - از بورس دالیان چین تا گمرک بصره عراق، از تصمیمات کمیته فولاد OECD - تا ظرفیت‌سازی‌های هند — هر سیگنالی که بر رقابت‌پذیری ایران اثر - می‌گذارد، پیش از بقیه به دست شما می‌رسد. + {t.sub}

    @@ -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) => ( 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 (
    - {/* Subtle grid texture */} -
    ) diff --git a/src/pages/Home/sections/LatestNewsBentoSection.tsx b/src/pages/Home/sections/LatestNewsBentoSection.tsx index 45fbf05..8501dd1 100644 --- a/src/pages/Home/sections/LatestNewsBentoSection.tsx +++ b/src/pages/Home/sections/LatestNewsBentoSection.tsx @@ -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 }).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(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 (
    @@ -116,7 +175,7 @@ export default function LatestNewsBentoSection() { آخرین نرخ‌های بازار
    - {PRICES.map(p => ( + {prices.map(p => (
    {p.label}
    diff --git a/src/pages/Home/sections/RiskSection.tsx b/src/pages/Home/sections/RiskSection.tsx index 74f318d..fc1c506 100644 --- a/src/pages/Home/sections/RiskSection.tsx +++ b/src/pages/Home/sections/RiskSection.tsx @@ -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 }).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 = { - 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)' }, + 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(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() {
    - پایش لحظه‌ای · {riskItems.length.toLocaleString('fa-IR')} سیگنال + {t.overline(riskItems.length)}
    -

    - نقشه ریسک صنعت +

    + {t.heading}

    - ریسک‌های ژئوپلیتیک، مقرراتی و بازار که در ماه‌های اخیر پایش شده‌اند — - با سطح‌بندی از بحرانی تا فرصت. + {t.sub}

    - همه ریسک‌ها ← + {t.all}
    {/* Legend row */}
    - {[ - { 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 => (
    {l.label} @@ -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 (
  • +
    + + {/* Newsletter issues */} +
    +
    +
    +
    {t.overline}
    +

    {t.heading}

    +
    + {t.archive} +
    + +
    + {ISSUES[lang].map((issue, i) => ( + +
    + {issue.num} +
    +
    +
    {t.issueLabel}
    +

    {issue.title}

    +
    {issue.date}
    +
    +
    + ))} +
    + + {/* Subscribe bar */} +
    + 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' }} + /> + +
    +
    + + {/* Major plants */} +
    +
    {t.overlinePlants}
    +
    +

    {t.headingPlants}

    + {t.allPlants} +
    +
    + {FACTORIES[lang].map((f, i) => ( + +
    +
    {f.name}
    +
    {f.location} · {f.type}
    +
    {f.capacity}
    +
    {t.capacity}
    + + ))} +
    +
    +
    + + ) +} diff --git a/src/pages/Home/sections/SustainabilitySection.tsx b/src/pages/Home/sections/SustainabilitySection.tsx new file mode 100644 index 0000000..eec188b --- /dev/null +++ b/src/pages/Home/sections/SustainabilitySection.tsx @@ -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 ( +
    +
    + + {/* گزارش‌های پایداری */} +
    +
    +
    +
    SUSTAINABILITY
    +

    گزارش‌های پایداری

    +
    + همه گزارش‌ها ← +
    + +
    + {REPORTS.map((r, i) => ( + +
    +
    + گزارش پایداری · {r.year} +
    +

    {r.title}

    +

    {r.summary}

    +
    + {r.tags.map(tag => ( + + {tag} + + ))} +
    + + ))} +
    +
    + + {/* MSTid.com banner */} + +
    +
    POWERED BY
    +
    MSTid.com
    +
    + مرجع جامع آمار و اطلاعات صنعت فولاد — داده‌های لحظه‌ای از کارخانه‌ها، بازارها و شاخص‌های اقتصادی +
    +
    +
    + {MSTID_STATS.map(s => ( +
    +
    {s.value}
    +
    {s.label}
    +
    + ))} +
    + + ورود به سامانه ← + +
    + +
    +
    + ) +} diff --git a/src/pages/Membership/Membership.tsx b/src/pages/Membership/Membership.tsx new file mode 100644 index 0000000..7452982 --- /dev/null +++ b/src/pages/Membership/Membership.tsx @@ -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(null) + + return ( +
    +
    +
    +
    +
    + {t.overline} +
    +

    {t.heading}

    +

    {t.sub}

    +
    +
    + +
    +
    + {plans.map((plan, i) => ( + + {plan.highlight &&
    } + {plan.highlight && ( +
    + {plan.recommended} +
    + )} +
    {plan.title}
    +
    {plan.subtitle}
    +
    + {plan.price} + {plan.period && {plan.period}} +
    +
      + {plan.features.map(f => ( +
    • + + {f} +
    • + ))} +
    + + + ))} +
    + + {selected && selected !== 'enterprise' && ( + +
    +

    {t.contactHeading}

    +

    {t.contactSub}

    +
    info@iransteel.ir
    + + )} +
    +
    + ) +} diff --git a/src/pages/Pulse/Pulse.tsx b/src/pages/Pulse/Pulse.tsx new file mode 100644 index 0000000..827b4e5 --- /dev/null +++ b/src/pages/Pulse/Pulse.tsx @@ -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 ( +
    +
    +
    +
    +
    + {t.overline} +
    +

    {t.heading}

    +

    {t.sub}

    +
    +
    + +
    +
    +
    + {t.statsLabel} +
    +
    + {stats.map((s, i) => ( +
    = 2 ? 'blur(5px)' : 'none', userSelect: i >= 2 ? 'none' : 'auto', pointerEvents: i >= 2 ? 'none' : 'auto' }}> +
    {s.value}
    +
    {s.unit}
    +
    {s.label}
    +
    {s.delta}
    +
    + ))} +
    +
    + + +
    +
    {t.gateAccess}
    +

    {t.gateHeading}

    +

    {t.gateSub}

    +
      + {t.features.map(item => ( +
    • + + {item} +
    • + ))} +
    + {!requested ? ( + + ) : ( +
    + {t.ctaDone} +
    + )} + +
    +
    + ) +} diff --git a/src/pages/Radar/Radar.tsx b/src/pages/Radar/Radar.tsx new file mode 100644 index 0000000..fef5c11 --- /dev/null +++ b/src/pages/Radar/Radar.tsx @@ -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 = { + 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 ( +
    +
    +
    +
    +
    + {t.overline} +
    +

    {t.heading}

    +

    {t.sub}

    +
    +
    + +
    +
    + {signals.map((s, i) => ( + +
    +
    + {s.tag} + {levelLabel[s.level as keyof typeof levelLabel]} +
    +

    {s.title}

    +
    {s.date}
    + + ))} +
    +
    +
    + ) +} diff --git a/src/pages/Technology/Technology.tsx b/src/pages/Technology/Technology.tsx new file mode 100644 index 0000000..0434bc6 --- /dev/null +++ b/src/pages/Technology/Technology.tsx @@ -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 ( +
    +
    +
    +
    +
    + {t.overline} +
    +

    {t.heading}

    +

    {t.sub}

    +
    +
    + +
    +
    + {techs.map((tech, i) => ( + +
    +
    {tech.num}
    +
    {tech.tag}
    +

    {tech.title}

    +

    {tech.summary}

    + + ))} +
    +
    +
    + ) +}