Security hardening + navbar restructure

Security fixes:
- JWT_SECRET/ADMIN_PASSWORD fail-fast on missing/weak values
- Switch admin auth from localStorage to httpOnly cookie
- Rate limiting on login (10/15min) and contact form (3/10min)
- CORS whitelist via ALLOWED_ORIGINS env var
- Helmet with CSP, frameguard
- Escape HTML in contact email and admin panel innerHTML
- Remove SVG from upload whitelist
- err.message no longer leaked in API responses
- MAIL_TO moved to env var
- JWT expiry 7d → 24h

Navbar:
- Rename: رادار آینده → آینده‌پژوهی و رصد هوشمند
- Rename: مرز فناوری → فناوری و نوآوری
- Rename: نبض صنعت → بازار و زنجیره فولاد
- Add: پایداری و فولاد سبز (/sustainability)
- Add: ژئوپلیتیک و اقتصاد جهانی (/geopolitics)
- Remove: رویدادها

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alireza 2026-06-01 14:37:30 +03:30
parent 1f72b528d7
commit 743888429b
13 changed files with 277 additions and 64 deletions

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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 = `<p class="muted">هنوز مطلبی ثبت نشده است.</p>`;
return;
}
const safeCoverUrl = (u) => /^\/uploads\/[a-zA-Z0-9._-]+$/.test(String(u || '')) ? u : '';
$('#list').innerHTML = items.map((a) => `
<article class="row" data-id="${a.id}">
<div class="row-cover" style="background-image:url('${a.coverImage || ''}')"></div>
<article class="row" data-id="${escapeAttr(a.id)}">
<div class="row-cover" style="background-image:url('${escapeAttr(safeCoverUrl(a.coverImage))}')"></div>
<div class="row-main">
<div class="row-meta">
${a.featured ? '<span class="pill red">ویژه</span>' : ''}
${a.category ? `<span class="pill">${a.category}</span>` : ''}
${a.publishDate ? `<span class="muted">${a.publishDate}</span>` : ''}
${a.category ? `<span class="pill">${escapeHtml(a.category)}</span>` : ''}
${a.publishDate ? `<span class="muted">${escapeHtml(a.publishDate)}</span>` : ''}
</div>
<h3>${a.title}</h3>
<p class="muted clamp-2">${a.summary || ''}</p>
<h3>${escapeHtml(a.title)}</h3>
<p class="muted clamp-2">${escapeHtml(a.summary)}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
@ -334,16 +331,17 @@ async function renderRisksList() {
$('#list').innerHTML = `<p class="muted">هنوز سیگنالی ثبت نشده است.</p>`;
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 => `
<article class="row risk-row" data-id="${r.id}">
<article class="row risk-row" data-id="${escapeAttr(r.id)}">
<div class="row-main">
<div class="row-meta">
<span class="pill level-${r.level}">${labelOf(r.level)}</span>
<strong>${r.name}</strong>
${r.date ? `<span class="muted">${r.date}</span>` : ''}
<span class="pill level-${SAFE_LEVELS.has(r.level) ? r.level : 'low'}">${labelOf(r.level)}</span>
<strong>${escapeHtml(r.name)}</strong>
${r.date ? `<span class="muted">${escapeHtml(r.date)}</span>` : ''}
</div>
<p class="muted clamp-2">${r.quote}</p>
<p class="muted clamp-2">${escapeHtml(r.quote)}</p>
</div>
<div class="row-actions">
<button data-action="edit">ویرایش</button>
@ -434,5 +432,4 @@ function renderRiskEditor(risk) {
});
}
if (getToken()) renderList();
else renderLogin();
api('/api/auth/me').then(() => renderList()).catch(() => renderLogin());

View File

@ -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) {

View File

@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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: `<h2>درخواست دسترسی — نبض صنعت</h2>
<p><strong>نام:</strong> ${name}</p>
<p><strong>شماره همراه:</strong> ${phone}</p>
<p><strong>ایمیل:</strong> ${email || ''}</p>`,
<p><strong>نام:</strong> ${escHtml(name)}</p>
<p><strong>شماره همراه:</strong> ${escHtml(phone)}</p>
<p><strong>ایمیل:</strong> ${escHtml(email) || ''}</p>`,
});
res.json({ ok: true });
} catch (err) {

View File

@ -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 }[] = [

View File

@ -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: <Special /> },
{ path: 'risks', element: <Risks /> },
{ path: 'scanner', element: <Scanner /> },
{ path: 'radar', element: <Radar /> },
{ path: 'technology', element: <Technology /> },
{ path: 'pulse', element: <Pulse /> },
{ path: 'membership', element: <Membership /> },
{ path: 'radar', element: <Radar /> },
{ path: 'technology', element: <Technology /> },
{ path: 'pulse', element: <Pulse /> },
{ path: 'sustainability', element: <Sustainability /> },
{ path: 'geopolitics', element: <Geopolitics /> },
{ path: 'membership', element: <Membership /> },
],
},
])

View File

@ -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 (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '80vh' }}>
<div style={{ borderBottom: '3px solid var(--ink)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 36, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>{t.overline}</span>
</div>
<h1 style={{ fontSize: 'clamp(28px,4vw,52px)', fontWeight: 900, letterSpacing: '-1.5px', color: 'var(--ink)', marginBottom: 12 }}>{t.heading}</h1>
<p style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.7, maxWidth: 560 }}>{t.sub}</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 80, paddingBottom: 80, textAlign: 'center' }}>
<p style={{ fontSize: 15, color: 'var(--ink-4)' }}>{t.soon}</p>
</div>
</div>
)
}

View File

@ -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 }),

View File

@ -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() {

View File

@ -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 (
<div dir={lang === 'fa' ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '80vh' }}>
<div style={{ borderBottom: '3px solid var(--ink)' }}>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 48, paddingBottom: 40 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<div style={{ width: 36, height: 2, background: 'var(--red)' }} />
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', textTransform: 'uppercase', color: 'var(--ink-4)' }}>{t.overline}</span>
</div>
<h1 style={{ fontSize: 'clamp(28px,4vw,52px)', fontWeight: 900, letterSpacing: '-1.5px', color: 'var(--ink)', marginBottom: 12 }}>{t.heading}</h1>
<p style={{ fontSize: 15, color: 'var(--ink-3)', lineHeight: 1.7, maxWidth: 560 }}>{t.sub}</p>
</div>
</div>
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 80, paddingBottom: 80, textAlign: 'center' }}>
<p style={{ fontSize: 15, color: 'var(--ink-4)' }}>{t.soon}</p>
</div>
</div>
)
}

View File

@ -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() {