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:
parent
1f72b528d7
commit
743888429b
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
101
panel/server.js
101
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, '>').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: `<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) {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,11 @@ const T = {
|
|||
───────────────────────────────────────────────────────── */
|
||||
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: '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 }[] = [
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
{
|
||||
|
|
@ -35,6 +37,8 @@ export const router = createBrowserRouter([
|
|||
{ path: 'radar', element: <Radar /> },
|
||||
{ path: 'technology', element: <Technology /> },
|
||||
{ path: 'pulse', element: <Pulse /> },
|
||||
{ path: 'sustainability', element: <Sustainability /> },
|
||||
{ path: 'geopolitics', element: <Geopolitics /> },
|
||||
{ path: 'membership', element: <Membership /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue