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 = `
+
+
+
+
+ `;
+
+ $('#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) => (
- <>
-
-
-
- {col.heading}
-
-
- {col.links.map(link => (
-
- ))}
-
-
- >
- ))}
+ {/* 4 links */}
+
+ {FOOTER_LINKS.map(link => (
+
+ ))}
+
{/* 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 */}
{META_LEFT.map((item) => (
(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}
))}
·
@@ -203,18 +209,26 @@ export default function Header() {
fontWeight: 600,
}}
>
- ورود
+ {lang === 'fa' ? 'ورود' : 'Login'}
-
{ 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
-
+ {lang === 'fa' ? 'EN' : 'FA'}
+
@@ -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 */}
-
+
@@ -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 (
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 !== 'همه' && (
-
- ({events.filter((e) => e.type === f.value).length})
+ {f.value !== 'all' && (
+
+ ({events.filter(e => e.type === f.value).length})
)}
@@ -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 && (
-
-
رویدادی در این دستهبندی یافت نشد
+
)}
- {/* 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 */}
-
+ {/* Dark overlay */}
+
+ {/* Grid texture */}
+
+ {/* Red glow */}
+
- {/* ─── Top meta row ─────────────────────────── */}
-
-
-
- گزارش ویژه · شماره ۰۱
-
-
- {featured.publishDate} · {featured.category}
-
-
-
-
- ISSN 2783-XXXX · VOL. XII
+
+
+ FUTURE STEEL POLICY INSTITUTE
-
+
- {/* ─── Main grid — 8/12 + 4/12 ─────────────── */}
-
- {/* ═══ Left col ═══ */}
-
- {/* Overline */}
-
-
-
- تحلیل سناریو · راهبردی
-
-
+ {t.h1a}
+
{t.h1b}
+ {t.h1c}
+
- {/* Massive editorial headline */}
-
- {(() => {
- const [main, sub] = featured.title.split(':')
- return (
- <>
- {main}
- {sub && (
-
- {sub.trim()}
-
- )}
- >
- )
- })()}
-
+ {/* Subtitle */}
+
+ {t.sub}
+
- {/* Lede */}
-
- مدلسازی کمی بر اساس دادههای تجارت ۱۵ کشور رقیب — از بدبینانه تا خوشبینانه،
- با توصیههای سیاستی مستقیم برای وزارت صمت و سازمان بنادر.
-
-
- {/* Stats inline row */}
-
- {STATS.map((s, i) => (
-
-
- {s.n}
-
-
- {s.label}
-
-
- ))}
-
-
- {/* Author + CTAs */}
-
-
-
- {featured.authorInitial}
-
-
-
- {featured.author}
-
-
- {featured.authorRole}
-
-
-
-
-
-
-
- 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"
- >
- خرید گزارش · {featured.price.toLocaleString('fa-IR')} تومان
-
- 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"
- >
- دانلود خلاصه رایگان
-
-
-
-
-
- {/* ═══ Right col: dossier mini-cover — frosted glass over ink ═══ */}
-
+ 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' }}
>
-
+ {t.cta1}
+
+ 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}
+
+
-
- Nº 01 / 2025
-
-
-
- DOSSIER
-
-
-
- ۰۴
-
-
-
- سناریو راهبردی
-
-
-
- برای آینده فولاد ایران در بازار جهانی ۱۴۰۴
-
-
-
- {[
- { k: 'صفحات', v: `${featured.pages.toLocaleString('fa-IR')} صفحه` },
- { k: 'نوع', v: 'گزارش راهبردی' },
- { k: 'انتشار', v: featured.publishDate },
- { k: 'مخاطب', v: 'سیاستگذار · مدیر ارشد' },
- ].map((row) => (
-
- {row.k}
- {row.v}
-
- ))}
-
-
-
+ {/* Video placeholder badge */}
+
+ ▶
+ Short film · Future of Steel
+
)
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 */}
+
+
+
+
+ {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' }}
+ />
+ { 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}
+
+
+
+
+ {/* Major plants */}
+
+
{t.overlinePlants}
+
+
+ {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 (
+
+
+
+ {/* گزارشهای پایداری */}
+
+
+
+
+ {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.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}
+
+ ))}
+
+ 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}
+
+
+ ))}
+
+
+ {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.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 ? (
+ 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}
+
+ ) : (
+
+ {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.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.heading}
+
{t.sub}
+
+
+
+
+
+ {techs.map((tech, i) => (
+
+
+ {tech.num}
+ {tech.tag}
+ {tech.title}
+ {tech.summary}
+
+ ))}
+
+
+
+ )
+}