diff --git a/panel/.env.example b/panel/.env.example index a556020..fd1eca5 100644 --- a/panel/.env.example +++ b/panel/.env.example @@ -1,14 +1,25 @@ # Copy this file to ".env" and fill in real values. -# JWT signing secret — change to a long random string in production -JWT_SECRET=please-change-this-to-a-long-random-string +# JWT signing secret — REQUIRED. Must be 32+ random chars. Never commit the real value. +# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" +JWT_SECRET=replace-with-64-random-hex-chars -# Default admin credentials used by `npm run seed` +# Default admin credentials used by `npm run seed` — REQUIRED, min 12 chars ADMIN_USERNAME=admin -ADMIN_PASSWORD=changeme +ADMIN_PASSWORD=replace-with-strong-password # Server port PORT=3001 # Public origin (used in returned image URLs). Set to your real domain in prod. PUBLIC_ORIGIN=http://localhost:3001 + +# Comma-separated origins allowed to call the API (your frontend URL) +ALLOWED_ORIGINS=http://localhost:5173 + +# Contact form recipients (comma-separated) +MAIL_TO=alirezaameria2@gmail.com, mtdemne@gmail.com + +# Gmail credentials for contact form emails +MAIL_USER=your-gmail@gmail.com +MAIL_PASS=your-gmail-app-password diff --git a/panel/package-lock.json b/panel/package-lock.json index f879b60..0805428 100644 --- a/panel/package-lock.json +++ b/panel/package-lock.json @@ -11,9 +11,12 @@ "bcryptjs": "^2.4.3", "better-sqlite3": "^11.3.0", "cheerio": "^1.2.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", + "express-rate-limit": "^7.5.0", + "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "nanoid": "^5.0.7", @@ -327,6 +330,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", @@ -696,6 +718,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -832,6 +869,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.2.0.tgz", + "integrity": "sha512-DRgTIUgnWcJ62KyarxxziuqYxKGnR6Rgg19BlbucN/dpmJbl1XOit6qvoOX0ZT+HhWe5OUVhU/a1zpGyc1xA0Q==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", diff --git a/panel/package.json b/panel/package.json index 58b5d9b..d204c12 100644 --- a/panel/package.json +++ b/panel/package.json @@ -14,9 +14,12 @@ "bcryptjs": "^2.4.3", "better-sqlite3": "^11.3.0", "cheerio": "^1.2.0", + "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.21.0", + "express-rate-limit": "^7.5.0", + "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "nanoid": "^5.0.7", diff --git a/panel/public/app.js b/panel/public/app.js index 4a460f6..44351d3 100644 --- a/panel/public/app.js +++ b/panel/public/app.js @@ -1,10 +1,6 @@ const $ = (sel, root = document) => root.querySelector(sel); const root = $('#app'); -const TOKEN_KEY = 'andishkade_panel_token'; -const getToken = () => localStorage.getItem(TOKEN_KEY); -const setToken = (t) => localStorage.setItem(TOKEN_KEY, t); -const clearToken = () => localStorage.removeItem(TOKEN_KEY); const CATEGORIES = ['بازار جهانی', 'سیاست‌گذاری', 'انرژی و ESG', 'صادرات', 'تولید داخلی', 'ریسک و بحران']; const TYPES = [ @@ -17,16 +13,14 @@ const TYPES = [ async function api(path, { method = 'GET', body, isForm = false } = {}) { const headers = {}; - const token = getToken(); - if (token) headers.Authorization = `Bearer ${token}`; if (body && !isForm) headers['Content-Type'] = 'application/json'; const res = await fetch(path, { method, + credentials: 'include', headers, body: isForm ? body : (body ? JSON.stringify(body) : undefined), }); if (res.status === 401) { - clearToken(); renderLogin('نیاز به ورود مجدد است'); throw new Error('unauthorized'); } @@ -63,7 +57,6 @@ function renderLogin(errorMsg = '') { method: 'POST', body: { username: fd.get('username'), password: fd.get('password') }, }); - setToken(data.token); renderList(); } catch { renderLogin('نام کاربری یا رمز عبور اشتباه است'); @@ -93,7 +86,10 @@ function wireTabs() { else if (btn.dataset.tab === 'risks') renderRisksList(); }); }); - $('#logoutBtn').addEventListener('click', () => { clearToken(); renderLogin(); }); + $('#logoutBtn').addEventListener('click', async () => { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {}); + renderLogin(); + }); } async function renderList() { @@ -113,17 +109,18 @@ async function renderList() { $('#list').innerHTML = `

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

`; return; } + const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : ''; $('#list').innerHTML = items.map((a) => ` -
-
+
+
${a.featured ? 'ویژه' : ''} - ${a.category ? `${a.category}` : ''} - ${a.publishDate ? `${a.publishDate}` : ''} + ${a.category ? `${escapeHtml(a.category)}` : ''} + ${a.publishDate ? `${escapeHtml(a.publishDate)}` : ''}
-

${a.title}

-

${a.summary || ''}

+

${escapeHtml(a.title)}

+

${escapeHtml(a.summary)}

@@ -334,16 +331,17 @@ async function renderRisksList() { $('#list').innerHTML = `

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

`; return; } - const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || lvl; + const SAFE_LEVELS = new Set(['critical', 'high', 'medium', 'low', 'opportunity']); + const labelOf = (lvl) => (RISK_LEVELS.find(r => r.value === lvl)?.label) || escapeHtml(lvl); $('#list').innerHTML = items.map(r => ` -
+
- ${labelOf(r.level)} - ${r.name} - ${r.date ? `${r.date}` : ''} + ${labelOf(r.level)} + ${escapeHtml(r.name)} + ${r.date ? `${escapeHtml(r.date)}` : ''}
-

${r.quote}

+

${escapeHtml(r.quote)}

@@ -434,5 +432,4 @@ function renderRiskEditor(risk) { }); } -if (getToken()) renderList(); -else renderLogin(); +api('/api/auth/me').then(() => renderList()).catch(() => renderLogin()); diff --git a/panel/seed.js b/panel/seed.js index 08458c0..e586b99 100644 --- a/panel/seed.js +++ b/panel/seed.js @@ -3,7 +3,11 @@ import bcrypt from 'bcryptjs'; import { db } from './db.js'; const username = process.env.ADMIN_USERNAME || 'admin'; -const password = process.env.ADMIN_PASSWORD || 'admin1234'; +const password = process.env.ADMIN_PASSWORD; +if (!password || password.length < 12) { + console.error('FATAL: ADMIN_PASSWORD env var not set or shorter than 12 chars.'); + process.exit(1); +} const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username); if (existing) { diff --git a/panel/server.js b/panel/server.js index 53e6013..c807b80 100644 --- a/panel/server.js +++ b/panel/server.js @@ -1,6 +1,9 @@ import 'dotenv/config'; import express from 'express'; import cors from 'cors'; +import cookieParser from 'cookie-parser'; +import rateLimit from 'express-rate-limit'; +import helmet from 'helmet'; import multer from 'multer'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; @@ -13,7 +16,11 @@ import { startScraperLoop, scrapeOnce } from './scraper.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 3001); -const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'; +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET || JWT_SECRET.length < 32) { + console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.'); + process.exit(1); +} const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`; const uploadsDir = path.join(__dirname, 'uploads'); @@ -30,20 +37,59 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { - if (/^image\/(jpe?g|png|webp|gif|svg\+xml)$/.test(file.mimetype)) cb(null, true); - else cb(new Error('Only image uploads are allowed')); + if (/^image\/(jpe?g|png|webp|gif)$/.test(file.mimetype)) cb(null, true); + else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)')); }, }); const app = express(); -app.use(cors({ origin: true, credentials: true })); +const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173') + .split(',').map(s => s.trim()); +app.use(cors({ + origin: (origin, cb) => { + if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true); + else cb(new Error('Not allowed by CORS')); + }, + credentials: true, +})); +app.use(cookieParser()); +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:", "blob:"], + connectSrc: ["'self'"], + fontSrc: ["'self'"], + objectSrc: ["'none'"], + frameAncestors: ["'none'"], + }, + }, + frameguard: { action: 'deny' }, +})); app.use(express.json({ limit: '2mb' })); app.use('/uploads', express.static(uploadsDir, { maxAge: '7d' })); app.use('/', express.static(path.join(__dirname, 'public'))); +const loginLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 10, + message: { error: 'too_many_attempts' }, + standardHeaders: true, + legacyHeaders: false, +}); + +const contactLimiter = rateLimit({ + windowMs: 10 * 60 * 1000, + max: 3, + message: { error: 'too_many_requests' }, + standardHeaders: true, + legacyHeaders: false, +}); + function authRequired(req, res, next) { - const header = req.headers.authorization || ''; - const token = header.startsWith('Bearer ') ? header.slice(7) : null; + const token = req.cookies?.session; if (!token) return res.status(401).json({ error: 'unauthorized' }); try { req.user = jwt.verify(token, JWT_SECRET); @@ -53,16 +99,29 @@ function authRequired(req, res, next) { } } -app.post('/api/auth/login', (req, res) => { +app.post('/api/auth/login', loginLimiter, (req, res) => { const { username, password } = req.body || {}; if (!username || !password) return res.status(400).json({ error: 'username_password_required' }); const row = db.prepare('SELECT id, username, password_hash FROM users WHERE username = ?').get(username); - if (!row) return res.status(401).json({ error: 'bad_credentials' }); - if (!bcrypt.compareSync(password, row.password_hash)) { + if (!row || !bcrypt.compareSync(password, row.password_hash)) { return res.status(401).json({ error: 'bad_credentials' }); } - const token = jwt.sign({ sub: row.id, username: row.username }, JWT_SECRET, { expiresIn: '7d' }); - res.json({ token, user: { id: row.id, username: row.username } }); + const token = jwt.sign({ sub: row.id, username: row.username }, JWT_SECRET, { expiresIn: '24h' }); + const isProd = process.env.NODE_ENV === 'production'; + res.cookie('session', token, { + httpOnly: true, + secure: isProd, + sameSite: 'strict', + maxAge: 24 * 60 * 60 * 1000, + path: '/', + }); + res.json({ user: { id: row.id, username: row.username } }); +}); + +app.post('/api/auth/logout', (_req, res) => { + const isProd = process.env.NODE_ENV === 'production'; + res.clearCookie('session', { httpOnly: true, secure: isProd, sameSite: 'strict', path: '/' }); + res.json({ ok: true }); }); app.get('/api/auth/me', authRequired, (req, res) => { @@ -234,7 +293,8 @@ app.post('/api/prices/refresh', authRequired, async (_req, res) => { const n = await scrapeOnce(); res.json({ ok: true, scraped: n }); } catch (err) { - res.status(500).json({ error: err.message }); + console.error('[scraper] refresh error:', err); + res.status(500).json({ error: 'scrape_failed' }); } }); @@ -243,10 +303,15 @@ app.get('/api/health', (_req, res) => res.json({ ok: true })); /* ── Contact form → email ─────────────────────────────── */ import nodemailer from 'nodemailer'; -app.post('/api/contact', async (req, res) => { +function escHtml(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +app.post('/api/contact', contactLimiter, async (req, res) => { const { name, phone, email } = req.body || {}; if (!name || !phone) return res.status(400).json({ error: 'name and phone required' }); + const mailTo = process.env.MAIL_TO || 'alirezaameria2@gmail.com, mtdemne@gmail.com'; const transporter = nodemailer.createTransport({ service: 'gmail', auth: { user: process.env.MAIL_USER, pass: process.env.MAIL_PASS }, @@ -255,12 +320,12 @@ app.post('/api/contact', async (req, res) => { try { await transporter.sendMail({ from: process.env.MAIL_USER, - to: 'alirezaameria2@gmail.com, mtdemne@gmail.com', - subject: `درخواست دسترسی نبض صنعت — ${name}`, + to: mailTo, + subject: `درخواست دسترسی نبض صنعت — ${escHtml(name)}`, html: `

درخواست دسترسی — نبض صنعت

-

نام: ${name}

-

شماره همراه: ${phone}

-

ایمیل: ${email || '—'}

`, +

نام: ${escHtml(name)}

+

شماره همراه: ${escHtml(phone)}

+

ایمیل: ${escHtml(email) || '—'}

`, }); res.json({ ok: true }); } catch (err) { diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx index 3f95ee6..788c690 100644 --- a/src/app/layout/Header.tsx +++ b/src/app/layout/Header.tsx @@ -23,11 +23,12 @@ const T = { DATA ───────────────────────────────────────────────────────── */ 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: 'Home', to: '/' }, + { fa: 'آینده‌پژوهی و رصد هوشمند', en: 'Foresight & Intelligence', to: '/radar' }, + { fa: 'پایداری و فولاد سبز', en: 'Sustainability & Green Steel', to: '/sustainability' }, + { fa: 'ژئوپلیتیک و اقتصاد جهانی', en: 'Geopolitics & Global Economy', to: '/geopolitics' }, + { fa: 'فناوری و نوآوری', en: 'Technology & Innovation', to: '/technology' }, + { fa: 'بازار و زنجیره فولاد', en: 'Market & Steel Chain', to: '/pulse' }, ] const META_LEFT: { fa: string; en: string }[] = [ diff --git a/src/app/router.tsx b/src/app/router.tsx index 2570ce3..3a18343 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -15,6 +15,8 @@ import Radar from '@/pages/Radar/Radar' import Technology from '@/pages/Technology/Technology' import Pulse from '@/pages/Pulse/Pulse' import Membership from '@/pages/Membership/Membership' +import Sustainability from '@/pages/Sustainability/Sustainability' +import Geopolitics from '@/pages/Geopolitics/Geopolitics' export const router = createBrowserRouter([ { @@ -32,10 +34,12 @@ export const router = createBrowserRouter([ { path: 'special', element: }, { path: 'risks', element: }, { path: 'scanner', element: }, - { path: 'radar', element: }, - { path: 'technology', element: }, - { path: 'pulse', element: }, - { path: 'membership', element: }, + { path: 'radar', element: }, + { path: 'technology', element: }, + { path: 'pulse', element: }, + { path: 'sustainability', element: }, + { path: 'geopolitics', element: }, + { path: 'membership', element: }, ], }, ]) diff --git a/src/pages/Geopolitics/Geopolitics.tsx b/src/pages/Geopolitics/Geopolitics.tsx new file mode 100644 index 0000000..2ab13dd --- /dev/null +++ b/src/pages/Geopolitics/Geopolitics.tsx @@ -0,0 +1,39 @@ +import { useLang } from '@/context/LangContext' + +const T = { + fa: { + overline: 'GEOPOLITICS & GLOBAL ECONOMY', + heading: 'ژئوپلیتیک و اقتصاد جهانی', + sub: 'تحلیل ریسک‌های کلان، سناریوهای ژئوپلیتیک و اثر آن‌ها بر صنعت فولاد ایران', + soon: 'محتوا به‌زودی منتشر می‌شود', + }, + en: { + overline: 'GEOPOLITICS & GLOBAL ECONOMY', + heading: 'Geopolitics & Global Economy', + sub: "Analysis of macro risks, geopolitical scenarios, and their impact on Iran's steel industry", + soon: 'Content coming soon', + }, +} + +export default function Geopolitics() { + const { lang } = useLang() + const t = T[lang] + + return ( +
+
+
+
+
+ {t.overline} +
+

{t.heading}

+

{t.sub}

+
+
+
+

{t.soon}

+
+
+ ) +} diff --git a/src/pages/Pulse/Pulse.tsx b/src/pages/Pulse/Pulse.tsx index 845c129..c72f52d 100644 --- a/src/pages/Pulse/Pulse.tsx +++ b/src/pages/Pulse/Pulse.tsx @@ -20,11 +20,11 @@ const STATS = { const T = { fa: { - overline: 'INDUSTRY PULSE', heading: 'نبض صنعت', + overline: 'MARKET & STEEL CHAIN', heading: 'بازار و زنجیره فولاد', sub: 'سامانه جامع آمار و اطلاعات صنعت فولاد ایران — داده‌های لحظه‌ای تولید، صادرات، واردات و قیمت‌ها', statsLabel: 'آمار کلیدی صنعت · ۱۴۰۳', gateAccess: 'دسترسی اعضا', - gateHeading: 'دسترسی کامل به سامانه نبض صنعت', + gateHeading: 'دسترسی کامل به سامانه بازار و زنجیره فولاد', gateSub: 'اعضای اندیشکده به پایگاه داده جامع فولاد ایران شامل آمار لحظه‌ای تولید، صادرات، واردات، قیمت‌های داخلی و بین‌المللی و ۵۰۰+ شاخص صنعتی دسترسی دارند.', features: [ 'داده‌های لحظه‌ای تولید کارخانه‌های بزرگ', @@ -36,11 +36,11 @@ const T = { ctaDone: '✓ درخواست شما ثبت شد. تیم ما ظرف ۴۸ ساعت با شما تماس می‌گیرد.', }, en: { - overline: 'INDUSTRY PULSE', heading: 'Industry Pulse', + overline: 'MARKET & STEEL CHAIN', heading: 'Market & Steel Chain', 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', + gateHeading: 'Full Access to Market & Steel Chain', 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', @@ -135,8 +135,9 @@ function MembershipForm({ lang }: { lang: 'fa' | 'en' }) { async function submit() { if (!name || !phone) return setStatus('sending') + const PANEL_API = import.meta.env.VITE_PANEL_API || 'http://localhost:3001' try { - const res = await fetch('http://localhost:3001/api/contact', { + const res = await fetch(`${PANEL_API}/api/contact`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, phone, email }), diff --git a/src/pages/Radar/Radar.tsx b/src/pages/Radar/Radar.tsx index fef5c11..f676ba9 100644 --- a/src/pages/Radar/Radar.tsx +++ b/src/pages/Radar/Radar.tsx @@ -32,8 +32,8 @@ const LEVEL_LABEL = { } 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' }, + fa: { overline: 'FORESIGHT & INTELLIGENCE', heading: 'آینده‌پژوهی و رصد هوشمند', sub: 'رصد سیگنال‌های جهانی و منطقه‌ای با اثر مستقیم بر صنعت فولاد ایران' }, + en: { overline: 'FORESIGHT & INTELLIGENCE', heading: 'Foresight & Intelligence', sub: 'Monitoring global and regional signals with direct impact on Iran\'s steel industry' }, } export default function Radar() { diff --git a/src/pages/Sustainability/Sustainability.tsx b/src/pages/Sustainability/Sustainability.tsx new file mode 100644 index 0000000..dd99d84 --- /dev/null +++ b/src/pages/Sustainability/Sustainability.tsx @@ -0,0 +1,39 @@ +import { useLang } from '@/context/LangContext' + +const T = { + fa: { + overline: 'SUSTAINABILITY & GREEN STEEL', + heading: 'پایداری و فولاد سبز', + sub: 'رصد الزامات ESG، گذار کم‌کربن و مسیر صنعت فولاد ایران به سمت پایداری', + soon: 'محتوا به‌زودی منتشر می‌شود', + }, + en: { + overline: 'SUSTAINABILITY & GREEN STEEL', + heading: 'Sustainability & Green Steel', + sub: "Monitoring ESG requirements, low-carbon transition, and Iran's steel industry path toward sustainability", + soon: 'Content coming soon', + }, +} + +export default function Sustainability() { + const { lang } = useLang() + const t = T[lang] + + return ( +
+
+
+
+
+ {t.overline} +
+

{t.heading}

+

{t.sub}

+
+
+
+

{t.soon}

+
+
+ ) +} diff --git a/src/pages/Technology/Technology.tsx b/src/pages/Technology/Technology.tsx index 0434bc6..9bf0829 100644 --- a/src/pages/Technology/Technology.tsx +++ b/src/pages/Technology/Technology.tsx @@ -21,8 +21,8 @@ const TECHS = { } 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' }, + fa: { overline: 'TECHNOLOGY & INNOVATION', heading: 'فناوری و نوآوری', sub: 'فناوری‌های نوظهور در صنعت فولاد جهان و افق کاربرد آن‌ها در ایران' }, + en: { overline: 'TECHNOLOGY & INNOVATION', heading: 'Technology & Innovation', sub: 'Emerging technologies in the global steel industry and their application horizon in Iran' }, } export default function Technology() {