feat: enhance RTL support and add service worker for PWA

- Updated CSS to improve touch actions and text size adjustment for RTL layout.
- Modified ticker animation for RTL direction.
- Implemented service worker registration for production builds to enable offline capabilities.
- Enhanced Archive page to utilize search parameters and dynamically generate categories.
- Improved AtAGlance component with new modal design and updated content structure.
- Added keyword animation feature to the AuthPage for a more engaging login experience.
- Created a new SearchResults component to aggregate and display search results from various content sources.
- Introduced a minimal service worker to cache responses and support offline functionality.
This commit is contained in:
alireza 2026-06-27 14:31:55 +03:30
parent dea0105899
commit b1ce43bc7b
18 changed files with 1149 additions and 303 deletions

View File

@ -7,7 +7,11 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#032340" />
<meta name="description" content="اندیشکده فولاد آینده — مرکز مستقل تحلیل راهبردی و سیاست‌گذاری صنعت فولاد" />
<!-- Vazir font preloads (critical weights) -->

View File

@ -417,10 +417,33 @@ for (const sql of [
'ALTER TABLE articles ADD COLUMN IF NOT EXISTS sources TEXT',
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS tags TEXT',
'ALTER TABLE radar_items ADD COLUMN IF NOT EXISTS source TEXT',
'ALTER TABLE members ADD COLUMN IF NOT EXISTS last_login TIMESTAMPTZ',
]) {
await pool.query(sql);
}
// per-member activity log (page views + element clicks) — logged-in users only
await pool.query(`
CREATE TABLE IF NOT EXISTS member_activity (
id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- 'view' | 'click'
path TEXT,
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_activity_member ON member_activity(member_id, created_at DESC);
`);
// newsletter subscribers (footer signup) — email is unique
await pool.query(`
CREATE TABLE IF NOT EXISTS subscribers (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`);
// promote the founding admin (lowest id) to owner if none exists yet
const { rows: ownerRows } = await pool.query("SELECT 1 FROM users WHERE role = 'owner' LIMIT 1");
if (ownerRows.length === 0) {

View File

@ -3,10 +3,12 @@ const root = $('#app');
const TAB_GROUPS = [
{ label: 'محتوا', tabs: ['glance', 'radarFuture', 'media', 'events', 'banners'] },
{ label: 'کاربران', tabs: ['members', 'newsletter'] },
];
const TAB_LABELS = {
glance: 'در یک نگاه', radarFuture: 'رادار آینده', media: 'ویدیوکست و پادکست',
events: 'رویدادها', banners: 'بنرها',
members: 'کاربران سایت', newsletter: 'خبرنامه',
};
@ -177,7 +179,7 @@ function topbarHtml(active, primaryLabel) {
`;
}
function wireTabs() {
document.querySelectorAll('.tabs button').forEach(btn => {
document.querySelectorAll('.tabs button, .groups button').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.tab;
if (t === 'glance') renderRadarList();
@ -185,6 +187,8 @@ function wireTabs() {
else if (t === 'media') renderMediaList();
else if (t === 'events') renderEventsList();
else if (t === 'banners') renderBannersList();
else if (t === 'members') renderMembersList();
else if (t === 'newsletter') renderNewsletterList();
});
});
$('#logoutBtn').addEventListener('click', async () => {
@ -950,6 +954,208 @@ function friendlyUserError(msg) {
return m;
}
// ═══════════════════════════════════════════════
// SITE MEMBERS (phone/OTP users)
// ═══════════════════════════════════════════════
function fmtDateTime(ts) {
if (!ts) return '—';
// pg returns timestamptz as "2026-06-27 08:01:07.749392+00" — space separator,
// microseconds, and a colon-less "+00" offset that Date can't parse. Normalize.
const iso = String(ts).replace(' ', 'T')
.replace(/(\.\d{3})\d+/, '$1') // trim micro→milliseconds
.replace(/([+-]\d{2})(\d{2})?$/, (_, h, m) => `${h}:${m || '00'}`); // +00 → +00:00
const d = new Date(iso);
if (isNaN(d)) return escapeHtml(String(ts));
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium', timeStyle: 'short', calendar: 'persian',
}).format(d);
} catch { return d.toISOString(); }
}
async function renderMembersList() {
root.innerHTML = `
${topbarHtml('members', '↻ بروزرسانی')}
<main class="page">
<h2>کاربران سایت</h2>
<p class="muted" style="margin:-4px 0 12px">کاربران ثبتنامی سایت (موبایل/کد یکبارمصرف). آخرین ورود، شماره تماس، مسدودسازی و حذف.</p>
<input id="memberSearch" placeholder="جستجو بر اساس نام، ایمیل یا شماره…" style="width:100%;max-width:420px;margin-bottom:14px;padding:9px 12px;border:1px solid #d6d6d6;border-radius:8px" />
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderMembersList());
let all = [];
function paint(items) {
if (!items.length) { $('#list').innerHTML = `<p class="muted">کاربری یافت نشد.</p>`; return; }
$('#list').innerHTML = items.map(m => `
<article class="row" data-id="${escapeAttr(m.id)}">
<div class="row-main">
<div class="row-meta">
<strong>${escapeHtml(m.name || 'بی‌نام')}</strong>
${m.isActive ? '' : '<span class="pill red">مسدود</span>'}
${m.phone ? `<span class="muted" style="direction:ltr">${escapeHtml(m.phone)}</span>` : '<span class="muted">بدون شماره</span>'}
</div>
<p class="muted clamp-2">آخرین ورود: ${fmtDateTime(m.lastLogin)} · عضویت: ${fmtDateTime(m.createdAt)}${m.purchases ? ` · ${m.purchases} خرید` : ''}</p>
</div>
<div class="row-actions">
<button data-action="view">جزئیات</button>
<button data-action="toggle">${m.isActive ? 'مسدود کردن' : 'رفع مسدودی'}</button>
<button data-action="delete" class="danger">حذف</button>
</div>
</article>
`).join('');
}
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
const m = all.find(x => String(x.id) === String(id));
if (btn.dataset.action === 'view') {
renderMemberDetail(id);
} else if (btn.dataset.action === 'toggle') {
try {
await api(`/api/members/${id}`, { method: 'PATCH', body: { isActive: !m.isActive } });
renderMembersList();
} catch (err) { alert('عملیات ناموفق بود: ' + err.message); }
} else if (btn.dataset.action === 'delete') {
if (!confirm('این کاربر برای همیشه حذف شود؟')) return;
try {
await api(`/api/members/${id}`, { method: 'DELETE' });
renderMembersList();
} catch (err) {
alert('حذف ناموفق بود' + (String(err.message).includes('forbidden') ? ' (فقط مالک پنل می‌تواند حذف کند)' : ': ' + err.message));
}
}
});
$('#memberSearch').addEventListener('input', (e) => {
const q = e.target.value.trim().toLowerCase();
paint(!q ? all : all.filter(m =>
(m.name || '').toLowerCase().includes(q) ||
(m.email || '').toLowerCase().includes(q) ||
(m.phone || '').includes(q)));
});
try {
all = await api('/api/members');
paint(all);
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری کاربران.</p>`;
}
}
async function renderMemberDetail(id) {
root.innerHTML = `
<header class="topbar">
<div class="brand">جزئیات کاربر</div>
<div class="actions"><button id="backBtn" class="ghost"> بازگشت</button></div>
</header>
<main class="page"><div id="detail"><p class="muted">در حال بارگذاری</p></div></main>
`;
$('#backBtn').addEventListener('click', () => renderMembersList());
try {
const m = await api(`/api/members/${id}`);
const field = (label, val, ltr) => `
<div style="display:flex;gap:8px;padding:10px 0;border-bottom:1px solid #eee">
<span class="muted" style="min-width:120px">${label}</span>
<strong${ltr ? ' style="direction:ltr"' : ''}>${val}</strong>
</div>`;
const buys = m.purchases?.length
? m.purchases.map(p => `<li>${escapeHtml(p.title || p.articleId)}${fmtDateTime(p.purchasedAt)}${p.amount ? ` (${p.amount.toLocaleString('fa-IR')} تومان)` : ''}</li>`).join('')
: '<li class="muted">خریدی ثبت نشده</li>';
$('#detail').innerHTML = `
<div class="card" style="max-width:560px;padding:20px 24px">
<h2 style="margin:0 0 6px">${escapeHtml(m.name || 'بی‌نام')} ${m.isActive ? '' : '<span class="pill red">مسدود</span>'}</h2>
${field('شماره موبایل:', m.phone ? escapeHtml(m.phone) : '—', true)}
${field('ایمیل:', m.email ? escapeHtml(m.email) : '—', true)}
${field('آخرین ورود:', fmtDateTime(m.lastLogin))}
${field('تاریخ عضویت:', fmtDateTime(m.createdAt))}
${field('وضعیت:', m.isActive ? 'فعال' : 'مسدود')}
<h3 style="margin:18px 0 6px">خریدها (${m.purchases?.length || 0})</h3>
<ul style="margin:0;padding-inline-start:18px">${buys}</ul>
<h3 style="margin:18px 0 6px">فعالیت اخیر</h3>
<div id="activity"><p class="muted">در حال بارگذاری</p></div>
<div style="display:flex;gap:10px;margin-top:20px">
<button id="toggleBtn">${m.isActive ? 'مسدود کردن' : 'رفع مسدودی'}</button>
<button id="delBtn" class="danger">حذف کاربر</button>
</div>
</div>`;
$('#toggleBtn').addEventListener('click', async () => {
try { await api(`/api/members/${id}`, { method: 'PATCH', body: { isActive: !m.isActive } }); renderMemberDetail(id); }
catch (err) { alert('عملیات ناموفق بود: ' + err.message); }
});
$('#delBtn').addEventListener('click', async () => {
if (!confirm('این کاربر برای همیشه حذف شود؟')) return;
try { await api(`/api/members/${id}`, { method: 'DELETE' }); renderMembersList(); }
catch (err) { alert('حذف ناموفق بود: ' + err.message); }
});
// activity timeline (loaded separately so the card renders instantly)
try {
const acts = await api(`/api/members/${id}/activity`);
if (!acts.length) {
$('#activity').innerHTML = `<p class="muted">فعالیتی ثبت نشده.</p>`;
} else {
$('#activity').innerHTML = `<ul style="margin:0;padding-inline-start:18px;max-height:260px;overflow:auto">` +
acts.map(a => `<li style="padding:3px 0">
<span class="pill${a.kind === 'click' ? ' red' : ''}">${a.kind === 'click' ? 'کلیک' : 'بازدید'}</span>
${a.kind === 'click' && a.label ? `«${escapeHtml(a.label)}» در ` : ''}<span style="direction:ltr">${escapeHtml(a.path || '')}</span>
<span class="muted"> ${fmtDateTime(a.at)}</span>
</li>`).join('') + `</ul>`;
}
} catch { $('#activity').innerHTML = `<p class="error">خطا در بارگذاری فعالیت.</p>`; }
} catch (err) {
$('#detail').innerHTML = `<p class="error">خطا در بارگذاری.</p>`;
}
}
// ═══════════════════════════════════════════════
// NEWSLETTER SUBSCRIBERS
// ═══════════════════════════════════════════════
async function renderNewsletterList() {
root.innerHTML = `
${topbarHtml('newsletter', '↻ بروزرسانی')}
<main class="page">
<h2>عضویت در خبرنامه</h2>
<p class="muted" style="margin:-4px 0 12px">ایمیلهایی که از فرم فوتر سایت ثبت شدهاند.</p>
<div id="list" class="list"><p class="muted">در حال بارگذاری</p></div>
</main>
`;
wireTabs();
$('#newBtn').addEventListener('click', () => renderNewsletterList());
try {
const items = await api('/api/newsletter');
if (!items.length) { $('#list').innerHTML = `<p class="muted">هنوز ایمیلی ثبت نشده.</p>`; return; }
$('#list').innerHTML = `<p class="muted" style="margin-bottom:10px">${items.length.toLocaleString('fa-IR')} ایمیل</p>` +
items.map(s => `
<article class="row" data-id="${escapeAttr(s.id)}">
<div class="row-main">
<div class="row-meta">
<strong style="direction:ltr">${escapeHtml(s.email)}</strong>
<span class="muted">${fmtDateTime(s.createdAt)}</span>
</div>
</div>
<div class="row-actions"><button data-action="delete" class="danger">حذف</button></div>
</article>`).join('');
$('#list').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action="delete"]');
if (!btn) return;
const id = btn.closest('.row').dataset.id;
if (!confirm('این ایمیل حذف شود؟')) return;
try { await api(`/api/newsletter/${id}`, { method: 'DELETE' }); renderNewsletterList(); }
catch (err) { alert('حذف ناموفق بود: ' + err.message); }
});
} catch (err) {
console.error(err);
$('#list').innerHTML = `<p class="error">خطا در بارگذاری.</p>`;
}
}
// ═══════════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════════

View File

@ -1,11 +1,37 @@
/*
اندیشکده فولاد Admin Panel UI (navy #032340 / gold #CD9E53)
Redesign: CSS-only. Markup/classes from app.js are unchanged.
*/
:root {
--ink: #1a1712;
--ink-3: #4a443c;
--ink-5: #8a8278;
--paper: #fbfaf7;
--rule: #e8e3d8;
--red: #b91c1c;
--green: #15803d;
--navy: #032340;
--navy-600: #0a3a5e;
--gold: #cd9e53;
--gold-700: #b8893d;
--gold-soft: #f7ecd7;
--paper: #f4f1ea;
--surface: #ffffff;
--surface-2: #faf8f3;
--ink: #1c2128;
--ink-2: #4b5563;
--ink-3: #6b7280;
--ink-5: #9aa1ac;
--rule: #e7e2d6;
--rule-soft: #f0ece2;
--red: #dc2626;
--red-soft: #fef2f2;
--green: #16a34a;
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
--shadow-sm: 0 1px 2px rgba(3,35,64,.06), 0 1px 3px rgba(3,35,64,.05);
--shadow-md: 0 6px 18px -6px rgba(3,35,64,.18);
--shadow-lg: 0 18px 44px -20px rgba(3,35,64,.32);
--font: 'Vazirmatn', 'Vazir', 'Segoe UI', Tahoma, system-ui, sans-serif;
}
* { box-sizing: border-box; }
@ -13,77 +39,111 @@
html, body {
margin: 0;
padding: 0;
font-family: Tahoma, 'Segoe UI', Vazir, sans-serif;
font-family: var(--font);
color: var(--ink);
background: var(--paper);
background:
radial-gradient(1200px 480px at 100% -10%, rgba(205,158,83,.10), transparent 60%),
radial-gradient(1000px 460px at -10% 0%, rgba(3,35,64,.06), transparent 55%),
var(--paper);
background-attachment: fixed;
font-size: 14px;
line-height: 1.6;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--gold-soft); color: var(--navy); }
/* nicer scrollbars */
* { scrollbar-width: thin; scrollbar-color: var(--rule) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: var(--rule); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--ink-5); background-clip: content-box; }
/* ── Buttons ───────────────────────────────────────────────── */
button {
font-family: inherit;
font-size: 13px;
font-weight: 700;
padding: 8px 16px;
border: 1px solid var(--ink);
background: transparent;
padding: 9px 18px;
border: 1px solid var(--rule);
background: var(--surface);
color: var(--ink);
cursor: pointer;
border-radius: 0;
transition: background .15s;
border-radius: var(--r-sm);
transition: background .16s ease, color .16s ease, border-color .16s ease, box-shadow .16s ease, transform .06s ease;
line-height: 1.4;
white-space: nowrap;
}
button:hover { background: var(--ink); color: var(--paper); }
button.primary { background: var(--ink); color: var(--paper); }
button.primary:hover { background: #000; }
button.ghost { border-color: var(--rule); color: var(--ink-3); }
button.danger { border-color: var(--red); color: var(--red); }
button.danger:hover { background: var(--red); color: white; }
button:hover { border-color: var(--navy); background: var(--surface-2); }
button:active { transform: translateY(1px); }
button.primary {
background: linear-gradient(180deg, var(--navy-600), var(--navy));
color: #fff;
border-color: var(--navy);
box-shadow: var(--shadow-sm);
}
button.primary:hover { filter: brightness(1.12); border-color: var(--navy); background: linear-gradient(180deg, var(--navy-600), var(--navy)); }
button.ghost { background: transparent; border-color: var(--rule); color: var(--ink-2); }
button.ghost:hover { background: var(--surface-2); color: var(--ink); }
button.danger { border-color: #f1b4b4; color: var(--red); background: var(--surface); }
button.danger:hover { background: var(--red); color: #fff; border-color: var(--red); }
/* ── Inputs ────────────────────────────────────────────────── */
input, textarea, select {
font-family: inherit;
font-size: 13px;
padding: 8px 10px;
font-size: 13.5px;
padding: 10px 13px;
border: 1px solid var(--rule);
background: white;
background: var(--surface);
color: var(--ink);
width: 100%;
border-radius: 0;
border-radius: var(--r-sm);
transition: border-color .16s, box-shadow .16s;
}
input::placeholder, textarea::placeholder { color: var(--ink-5); }
input:focus, textarea:focus, select:focus {
outline: 2px solid var(--ink);
outline-offset: -2px;
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(205,158,83,.22);
}
textarea { resize: vertical; min-height: 90px; line-height: 1.7; }
label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--ink-3);
color: var(--ink-2);
margin-bottom: 4px;
letter-spacing: .1px;
}
label > input, label > textarea, label > select {
margin-top: 6px;
font-weight: 400;
label > input, label > textarea, label > select { margin-top: 7px; font-weight: 400; }
/* ── Utilities ─────────────────────────────────────────────── */
.muted { color: var(--ink-3); font-size: 12px; }
.error, .alert-error {
color: #991b1b;
background: var(--red-soft);
padding: 10px 14px;
border: 1px solid #f3c7c7;
border-radius: var(--r-sm);
font-size: 12.5px;
font-weight: 600;
}
textarea { resize: vertical; min-height: 80px; }
.muted { color: var(--ink-5); font-size: 12px; }
.error {
color: var(--red);
background: #fef2f2;
padding: 8px 12px;
border-right: 3px solid var(--red);
font-size: 12px;
}
.clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
.alert {
padding: 10px 14px;
border-radius: var(--r-sm);
background: var(--gold-soft);
border: 1px solid #ecd8b3;
color: var(--gold-700);
font-size: 12.5px;
font-weight: 600;
}
.clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
/* ── Auth screen ───────────────────────────────────────────── */
.auth-wrap {
min-height: 100vh;
display: flex;
@ -91,56 +151,103 @@ textarea { resize: vertical; min-height: 80px; }
justify-content: center;
padding: 24px;
}
.card {
background: white;
.card, .auth-card {
background: var(--surface);
border: 1px solid var(--rule);
padding: 32px;
padding: 36px 34px;
width: 100%;
max-width: 380px;
max-width: 400px;
border-radius: var(--r-lg);
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
.card h1 { font-size: 18px; margin: 0 0 4px; }
.card p { margin: 0 0 24px; }
.auth-card::before {
content: '';
position: absolute; inset: 0 0 auto 0; height: 5px;
background: linear-gradient(90deg, var(--navy), var(--gold));
}
.card h1 { font-size: 20px; font-weight: 900; margin: 6px 0 4px; color: var(--navy); letter-spacing: -.3px; }
.card p { margin: 0 0 22px; color: var(--ink-3); }
.card label { margin-top: 14px; }
.card button { width: 100%; margin-top: 20px; }
.card button { width: 100%; margin-top: 22px; padding: 12px; font-size: 14px; }
/* ── Top bar ───────────────────────────────────────────────── */
.topbar {
background: white;
border-bottom: 1px solid var(--rule);
position: sticky;
top: 0;
z-index: 10;
z-index: 20;
box-shadow: var(--shadow-sm);
}
.topbar-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 32px;
border-bottom: 1px solid var(--rule);
gap: 16px;
padding: 14px 28px;
background: linear-gradient(120deg, var(--navy) 0%, var(--navy-600) 100%);
color: #fff;
flex-wrap: wrap;
}
.topbar .brand { font-weight: 900; font-size: 15px; letter-spacing: -.3px; }
.topbar .actions { display: flex; gap: 10px; }
.groups {
.topbar .brand {
font-weight: 900;
font-size: 16px;
letter-spacing: -.3px;
color: #fff;
display: flex;
gap: 4px;
align-items: center;
gap: 10px;
}
.topbar .brand::before {
content: '';
width: 9px; height: 9px; border-radius: 50%;
background: var(--gold);
box-shadow: 0 0 0 4px rgba(205,158,83,.22);
}
.topbar .actions { display: flex; gap: 10px; }
/* buttons living on the navy bar */
.topbar-row .actions button { box-shadow: none; }
.topbar-row .actions button.primary {
background: linear-gradient(180deg, #e0b269, var(--gold));
color: var(--navy);
border-color: transparent;
font-weight: 800;
}
.topbar-row .actions button.primary:hover { filter: brightness(1.06); }
.topbar-row .actions button.ghost {
background: rgba(255,255,255,.08);
border-color: rgba(255,255,255,.28);
color: #fff;
}
.topbar-row .actions button.ghost:hover { background: rgba(255,255,255,.18); }
/* group switcher (on navy) */
.groups { display: flex; gap: 6px; }
.groups .group-btn {
border: 1px solid var(--rule);
background: transparent;
color: var(--ink-3);
border: 1px solid rgba(255,255,255,.22);
background: rgba(255,255,255,.06);
color: rgba(255,255,255,.82);
font-size: 13px;
font-weight: 700;
padding: 6px 16px;
border-radius: 20px;
padding: 7px 18px;
border-radius: 999px;
}
.groups .group-btn:hover { background: rgba(255,255,255,.16); color: #fff; }
.groups .group-btn.group-active {
background: var(--gold);
color: var(--navy);
border-color: var(--gold);
box-shadow: 0 2px 10px -2px rgba(205,158,83,.6);
}
.groups .group-btn:hover { background: var(--rule); color: var(--ink); }
.groups .group-btn.group-active { background: var(--ink); color: var(--paper); border-color: var(--ink); }
/* sub-tabs (white strip) */
.tabs {
display: flex;
gap: 0;
padding: 0 32px;
background: white;
gap: 2px;
padding: 0 28px;
background: var(--surface);
border-bottom: 1px solid var(--rule);
overflow-x: auto;
}
.tabs button {
border: none;
@ -148,47 +255,49 @@ textarea { resize: vertical; min-height: 80px; }
color: var(--ink-3);
font-size: 13px;
font-weight: 700;
padding: 10px 16px;
border-bottom: 2px solid transparent;
padding: 13px 16px 11px;
border-bottom: 2.5px solid transparent;
border-radius: 0;
white-space: nowrap;
}
.tabs button:hover { background: transparent; color: var(--ink); }
.tabs button.tab-active { color: var(--ink); border-bottom-color: var(--red); }
.tabs button:hover { background: transparent; color: var(--navy); border-bottom-color: var(--rule); }
.tabs button.tab-active { color: var(--navy); border-bottom-color: var(--gold); }
.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 16px; }
.cat-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 20px;
/* ── Page shell ────────────────────────────────────────────── */
.page, .page-body { max-width: 1120px; margin: 0 auto; padding: 34px 28px 64px; }
.page h2 {
font-size: 24px;
font-weight: 900;
letter-spacing: -.5px;
margin: 0 0 6px;
color: var(--navy);
}
.page > .muted { margin-bottom: 18px; }
/* ── Category filter chips ─────────────────────────────────── */
.cat-bar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 22px; }
.cat-btn {
padding: 4px 12px;
border-radius: 20px;
padding: 6px 15px;
border-radius: 999px;
border: 1px solid var(--rule);
background: transparent;
color: var(--fg);
font-size: 12px;
background: var(--surface);
color: var(--ink-2);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: background .15s, color .15s;
transition: all .15s;
}
.cat-btn:hover { border-color: var(--gold); color: var(--navy); }
.cat-btn.active {
background: var(--navy);
color: #fff;
border-color: var(--navy);
}
.cat-btn:hover { background: var(--rule); }
.cat-btn.active { background: var(--accent, #2563eb); color: #fff; border-color: transparent; }
/* ── Content-type picker grid ──────────────────────────────── */
.type-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 16px;
margin-top: 8px;
}
@ -196,107 +305,176 @@ textarea { resize: vertical; min-height: 80px; }
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 20px 20px 18px;
gap: 8px;
padding: 22px;
border: 1px solid var(--rule);
border-radius: 4px;
background: white;
border-radius: var(--r-md);
background: var(--surface);
cursor: pointer;
text-align: right;
transition: border-color .15s, box-shadow .15s;
box-shadow: var(--shadow-sm);
transition: border-color .16s, box-shadow .16s, transform .16s;
}
.type-card:hover { border-color: var(--ink); box-shadow: 0 2px 8px rgba(0,0,0,.08); background: white; color: var(--ink); }
.type-icon { font-size: 28px; line-height: 1; }
.type-card strong { font-size: 15px; font-weight: 800; }
.type-desc { font-size: 12px; color: var(--ink-5); font-weight: 400; line-height: 1.4; }
.type-card:hover {
border-color: var(--gold);
box-shadow: var(--shadow-md);
transform: translateY(-3px);
}
.type-icon {
font-size: 26px; line-height: 1;
width: 50px; height: 50px;
display: flex; align-items: center; justify-content: center;
background: var(--gold-soft);
border-radius: var(--r-md);
}
.type-card strong { font-size: 15px; font-weight: 800; color: var(--navy); }
.type-desc { font-size: 12px; color: var(--ink-3); font-weight: 400; line-height: 1.5; }
/* ── List + rows (card style) ──────────────────────────────── */
.list {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
gap: 10px;
background: transparent;
border: none;
}
.row {
display: grid;
grid-template-columns: 88px 1fr auto;
gap: 16px;
grid-template-columns: 92px 1fr auto;
gap: 18px;
align-items: center;
background: white;
padding: 14px 18px;
background: var(--surface);
padding: 14px 16px;
border: 1px solid var(--rule);
border-radius: var(--r-md);
box-shadow: var(--shadow-sm);
transition: border-color .16s, box-shadow .16s, transform .16s;
}
.row:hover { border-color: var(--gold); box-shadow: var(--shadow-md); transform: translateY(-2px); }
.row-cover {
width: 88px;
height: 66px;
background-color: #eee;
width: 92px;
height: 68px;
background-color: var(--surface-2);
background-size: cover;
background-position: center;
border: 1px solid var(--rule);
border-radius: var(--r-sm);
}
.row-main h3 { font-size: 14px; font-weight: 700; margin: 4px 0; }
.row-main p { margin: 0; }
.row-main { min-width: 0; }
.row-main h3 { font-size: 14.5px; font-weight: 800; margin: 5px 0; color: var(--ink); }
.row-main p { margin: 0; color: var(--ink-3); }
.row-meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.row-actions { display: flex; gap: 8px; }
.row-actions button { padding: 7px 14px; font-size: 12.5px; }
/* ── Pills / badges ────────────────────────────────────────── */
.pill {
display: inline-block;
font-size: 10px;
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 700;
padding: 2px 8px;
background: #f2efe8;
color: var(--ink-3);
letter-spacing: .5px;
padding: 3px 9px;
background: var(--gold-soft);
color: var(--gold-700);
letter-spacing: .3px;
border-radius: 999px;
}
.pill.red { background: var(--red); color: white; }
.pill.green { background: var(--green); color: white; }
.pill.red { background: var(--red); color: #fff; }
.pill.green { background: var(--green); color: #fff; }
.pill.level-critical { background: #7f1d1d; color: #fff; }
.pill.level-high { background: #b91c1c; color: #fff; }
.pill.level-medium { background: #b45309; color: #fff; }
.pill.level-low { background: #166534; color: #fff; }
.pill.level-opportunity { background: #1e40af; color: #fff; }
.risk-row { grid-template-columns: 1fr auto; }
.risk-row .row-meta strong { font-size: 13px; color: var(--navy); }
/* ── Avatar ────────────────────────────────────────────────── */
.row-avatar {
width: 44px; height: 44px; border-radius: 50%;
background: #032340; color: #CD9E53;
width: 46px; height: 46px; border-radius: 50%;
background: linear-gradient(140deg, var(--navy), var(--navy-600));
color: var(--gold);
display: flex; align-items: center; justify-content: center;
font-size: 16px; font-weight: 800; flex-shrink: 0;
box-shadow: var(--shadow-sm);
}
.editor .grid {
/* ── Editor forms ──────────────────────────────────────────── */
.editor .grid, form.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
gap: 20px;
background: var(--surface);
border: 1px solid var(--rule);
border-radius: var(--r-lg);
padding: 26px;
box-shadow: var(--shadow-sm);
}
.editor .col.span-2 { grid-column: 1 / -1; }
.editor .row-end { display: flex; justify-content: flex-start; }
.editor .col.span-2, .col.span-2 { grid-column: 1 / -1; }
.editor .row-end, .row-end { display: flex; justify-content: flex-start; }
.checkbox {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 8px;
gap: 9px;
margin-top: 22px;
background: var(--surface-2);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
padding: 10px 14px;
}
.checkbox input { width: auto; margin: 0; }
.checkbox input { width: auto; margin: 0; accent-color: var(--gold); width: 17px; height: 17px; }
.checkbox span { font-weight: 700; color: var(--ink); }
.cover-row {
display: flex;
gap: 16px;
align-items: center;
margin-top: 6px;
}
.cover-row { display: flex; gap: 16px; align-items: center; margin-top: 6px; }
.cover-row input[type=file] { flex: 1; }
.cover-row img {
width: 120px;
height: 80px;
width: 130px; height: 86px;
object-fit: cover;
background: #eee;
background: var(--surface-2);
border: 1px solid var(--rule);
border-radius: var(--r-sm);
}
.cover-row img[src=""] { visibility: hidden; }
@media (max-width: 720px) {
.editor .grid { grid-template-columns: 1fr; }
.editor .col.span-2 { grid-column: 1; }
.topbar { padding: 12px 16px; }
.page { padding: 16px; }
/* block editor (article body) */
.blocks-editor {
display: flex;
flex-direction: column;
gap: 12px;
background: var(--surface-2);
border: 1px dashed var(--rule);
border-radius: var(--r-md);
padding: 16px;
margin-top: 8px;
}
/* file inputs */
input[type=file] {
padding: 8px;
font-size: 12.5px;
background: var(--surface-2);
cursor: pointer;
}
input[type=file]::file-selector-button {
font-family: inherit; font-weight: 700; font-size: 12px;
border: 1px solid var(--rule); background: var(--surface);
color: var(--navy); padding: 7px 14px; border-radius: var(--r-sm);
cursor: pointer; margin-inline-end: 12px;
}
input[type=file]::file-selector-button:hover { border-color: var(--gold); }
/* ── Responsive ────────────────────────────────────────────── */
@media (max-width: 760px) {
.editor .grid, form.grid { grid-template-columns: 1fr; padding: 18px; }
.editor .col.span-2, .col.span-2 { grid-column: 1; }
.topbar-row { padding: 12px 16px; }
.tabs { padding: 0 12px; }
.page, .page-body { padding: 20px 16px 48px; }
.row { grid-template-columns: 64px 1fr; }
.row-actions { grid-column: 1 / -1; }
.row-cover { width: 64px; height: 48px; }
.row-cover { width: 64px; height: 50px; }
}

View File

@ -99,10 +99,14 @@ app.set('trust proxy', 1); // honor X-Forwarded-Proto from the reverse proxy
const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https';
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173')
.split(',').map(s => s.trim());
// any localhost/127.0.0.1 origin (any port) is allowed in addition to ALLOWED_ORIGINS —
// the panel serves its own SPA, whose module scripts fetch same-origin with an Origin
// header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS
// headers) rather than a thrown error, so a bad cross-origin request never 500s static assets.
const isLocalhost = (o) => /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o);
app.use(cors({
origin: (origin, cb) => {
if (!origin || ALLOWED_ORIGINS.includes(origin)) cb(null, true);
else cb(new Error('Not allowed by CORS'));
cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin));
},
credentials: true,
}));
@ -1324,6 +1328,80 @@ app.delete('/api/users/:id', authRequired, ownerRequired, async (req, res) => {
res.status(204).end();
});
// ---------- site members (phone/OTP users) — admin management ----------
app.get('/api/members', authRequired, async (_req, res) => {
const rows = await db.prepare(`
SELECT m.id, m.name, m.email, m.phone, m.is_active, m.created_at, m.last_login,
(SELECT COUNT(*) FROM purchases p WHERE p.member_id = m.id) AS purchases
FROM members m ORDER BY m.last_login DESC NULLS LAST, m.created_at DESC
`).all();
res.json(rows.map((r) => ({
id: r.id, name: r.name, email: r.email, phone: r.phone,
isActive: !!r.is_active, createdAt: r.created_at, lastLogin: r.last_login,
purchases: Number(r.purchases) || 0,
})));
});
app.get('/api/members/:id', authRequired, async (req, res) => {
const id = Number(req.params.id);
const r = await db.prepare('SELECT id, name, email, phone, avatar, is_active, created_at, updated_at, last_login FROM members WHERE id = ?').get(id);
if (!r) return res.status(404).json({ error: 'not_found' });
const purchases = await db.prepare(`
SELECT p.article_id, p.amount, p.purchased_at, a.title
FROM purchases p LEFT JOIN articles a ON a.id = p.article_id
WHERE p.member_id = ? ORDER BY p.purchased_at DESC
`).all(id);
res.json({
id: r.id, name: r.name, email: r.email, phone: r.phone, avatar: r.avatar,
isActive: !!r.is_active, createdAt: r.created_at, updatedAt: r.updated_at, lastLogin: r.last_login,
purchases: purchases.map((p) => ({ articleId: p.article_id, title: p.title, amount: p.amount, purchasedAt: p.purchased_at })),
});
});
// block / unblock — body { isActive: boolean }
app.patch('/api/members/:id', authRequired, async (req, res) => {
const id = Number(req.params.id);
const isActive = req.body?.isActive ? 1 : 0;
const info = await db.prepare('UPDATE members SET is_active = ?, updated_at = now() WHERE id = ?').run(isActive, id);
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.json({ id, isActive: !!isActive });
});
app.delete('/api/members/:id', authRequired, ownerRequired, async (req, res) => {
const info = await db.prepare('DELETE FROM members WHERE id = ?').run(Number(req.params.id));
if (info.changes === 0) return res.status(404).json({ error: 'not_found' });
res.status(204).end();
});
// admin: recent activity (page views + clicks) for one member
app.get('/api/members/:id/activity', authRequired, async (req, res) => {
const rows = await db.prepare(
'SELECT kind, path, label, created_at FROM member_activity WHERE member_id = ? ORDER BY created_at DESC LIMIT 200'
).all(Number(req.params.id));
res.json(rows.map((r) => ({ kind: r.kind, path: r.path, label: r.label, at: r.created_at })));
});
// ---------- newsletter subscribers ----------
// public: footer signup. dedupes silently on repeat email.
app.post('/api/newsletter', contactLimiter, async (req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return res.status(400).json({ error: 'ایمیل نامعتبر است' });
await db.prepare('INSERT INTO subscribers (email) VALUES (?) ON CONFLICT (email) DO NOTHING').run(email);
res.status(201).json({ ok: true });
});
// admin: list subscribers
app.get('/api/newsletter', authRequired, async (_req, res) => {
const rows = await db.prepare('SELECT id, email, created_at FROM subscribers ORDER BY created_at DESC').all();
res.json(rows.map((r) => ({ id: r.id, email: r.email, createdAt: r.created_at })));
});
app.delete('/api/newsletter/:id', authRequired, async (req, res) => {
const info = await db.prepare('DELETE FROM subscribers WHERE id = ?').run(Number(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', async (_req, res) => {
const rows = await db
@ -1587,6 +1665,7 @@ app.post('/api/members/register', loginLimiter, async (req, res) => {
const info = await db.prepare('INSERT INTO members (name, email, password_hash, phone) VALUES (?, ?, ?, ?) RETURNING id').run(name.trim(), email.toLowerCase().trim(), hash, phone ? String(phone).trim() : null);
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
});
@ -1600,6 +1679,7 @@ app.post('/api/members/login', loginLimiter, async (req, res) => {
if (!row.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
const token = jwt.sign({ sub: row.id, email: row.email, name: row.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(row.id);
res.json({ user: { id: row.id, name: row.name, email: row.email } });
});
@ -1613,7 +1693,12 @@ app.post('/api/members/logout', (req, res) => {
const otpStore = new Map(); // phone -> { code, expires }
async function sendOtpSms(phone, code, template) {
const key = process.env.KAVENEGAR_API_KEY;
if (!key) throw new Error('otp_not_configured');
if (!key) {
// ponytail: dev fallback — no SMS provider configured (local only). Print the code
// so OTP login can be tested without Kavenegar. In prod the key is always set.
console.log(`\n[otp:dev] no KAVENEGAR_API_KEY — code for ${phone}: ${code}\n`);
return;
}
const url = `https://api.kavenegar.com/v1/${key}/verify/lookup.json?receptor=${encodeURIComponent(phone)}&token=${encodeURIComponent(code)}&template=${encodeURIComponent(template)}`;
const r = await fetch(url);
const j = await r.json().catch(() => null);
@ -1652,6 +1737,7 @@ app.post('/api/members/otp/verify', loginLimiter, async (req, res) => {
if (!member.is_active) return res.status(403).json({ error: 'حساب کاربری غیرفعال است' });
const token = jwt.sign({ sub: member.id, email: member.email, name: member.name, role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(member.id);
res.json({ user: { id: member.id, name: member.name, email: member.email } });
});
@ -1672,9 +1758,20 @@ app.post('/api/members/register-verify', loginLimiter, async (req, res) => {
.run(name.trim(), email.toLowerCase().trim(), hash, phone);
const token = jwt.sign({ sub: info.lastInsertRowid, email, name: name.trim(), role: 'member' }, JWT_SECRET, { expiresIn: '30d' });
res.cookie('member_session', token, memberCookieOpts(req));
await db.prepare('UPDATE members SET last_login = now() WHERE id = ?').run(info.lastInsertRowid);
res.status(201).json({ user: { id: info.lastInsertRowid, name: name.trim(), email } });
});
/* activity beacon — logged-in members only. fire-and-forget from the site. */
app.post('/api/members/activity', memberRequired, async (req, res) => {
const kind = req.body?.kind === 'click' ? 'click' : 'view';
const path = String(req.body?.path || '').slice(0, 300);
const label = req.body?.label != null ? String(req.body.label).slice(0, 200) : null;
await db.prepare('INSERT INTO member_activity (member_id, kind, path, label) VALUES (?, ?, ?, ?)')
.run(req.member.sub, kind, path, label);
res.status(204).end();
});
/* set password via SMS code — for members who don't know their current password (e.g. OTP signups) */
app.post('/api/members/me/password-otp/send', memberRequired, async (req, res) => {
const row = await db.prepare('SELECT phone FROM members WHERE id = ?').get(req.member.sub);

View File

@ -1 +1 @@
{"name":"اندیشکده فولاد آینده","short_name":"فولاد آینده","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#032340","background_color":"#faf7f4","display":"standalone"}
{"name":"اندیشکده فولاد آینده","short_name":"فولاد آینده","start_url":"/","scope":"/","display":"standalone","orientation":"portrait","theme_color":"#032340","background_color":"#faf7f4","lang":"fa","dir":"rtl","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png","purpose":"any maskable"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png","purpose":"any maskable"}]}

29
public/sw.js Normal file
View File

@ -0,0 +1,29 @@
// ponytail: minimal runtime-cache SW. enough to make the app installable
// (Chrome needs a fetch handler) + offline shell. no precache list — Vite
// hashes bundle names at build, so we cache on first fetch instead.
const CACHE = 'sf-v1'
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
).then(() => self.clients.claim())
)
})
self.addEventListener('fetch', (e) => {
const req = e.request
if (req.method !== 'GET' || new URL(req.url).origin !== self.location.origin) return
// network-first: always try fresh, fall back to cache offline
e.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone()
caches.open(CACHE).then((c) => c.put(req, copy))
return res
})
.catch(() => caches.match(req).then((hit) => hit || caches.match('/')))
)
})

View File

@ -1,6 +1,7 @@
import { useState, type FC } from 'react'
import { Link } from 'react-router-dom'
import { MapPin, Phone, Mail, ChevronsUp } from 'lucide-react'
import { BASE } from '@/context/AuthContext'
function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
@ -146,12 +147,37 @@ const CONTACT = [
function NewsletterBox() {
const [email, setEmail] = useState('')
const [done, setDone] = useState(false)
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const submit = async () => {
const e = email.trim()
if (!e || busy) return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) { setErr('ایمیل نامعتبر است'); return }
setBusy(true); setErr('')
try {
const res = await fetch(`${BASE}/api/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: e }),
})
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || 'خطا در ثبت')
setDone(true)
} catch (x) {
setErr(x instanceof Error ? x.message : 'خطا در ثبت')
} finally {
setBusy(false)
}
}
return (
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff', marginBottom: 12 }}>عضویت در خبرنامه اندیشکده</div>
<div style={{ display: 'flex', border: '1px solid rgba(255,255,255,0.18)', borderRadius: 8, overflow: 'hidden', background: 'rgba(255,255,255,0.05)' }}>
<input
type="email" value={email} onChange={e => setEmail(e.target.value)}
type="email" value={email}
onChange={e => { setEmail(e.target.value); if (err) setErr('') }}
onKeyDown={e => e.key === 'Enter' && submit()}
placeholder="ایمیل خود را وارد کنید..." disabled={done}
style={{
flex: 1, border: 'none', padding: '11px 14px', fontSize: 13, fontFamily: 'inherit',
@ -159,15 +185,16 @@ function NewsletterBox() {
}}
/>
<button
onClick={() => { if (email) setDone(true) }} disabled={done}
onClick={submit} disabled={done || busy}
style={{
background: GOLD, color: NAVY, border: 'none', padding: '11px 22px',
fontSize: 12, fontWeight: 800, fontFamily: 'inherit', cursor: done ? 'default' : 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
fontSize: 12, fontWeight: 800, fontFamily: 'inherit', cursor: (done || busy) ? 'default' : 'pointer', whiteSpace: 'nowrap', flexShrink: 0,
}}
>
{done ? '✓ ثبت شد' : 'ثبت'}
{done ? '✓ ثبت شد' : busy ? '...' : 'ثبت'}
</button>
</div>
{err && <div style={{ fontSize: 11, color: '#fca5a5', marginTop: 6 }}>{err}</div>}
</div>
)
}

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
import type { CSSProperties } from 'react'
import { NavLink, Link } from 'react-router-dom'
import { NavLink, Link, useNavigate } from 'react-router-dom'
import { Search, Menu, X, ChevronDown, Radar, Leaf, Globe, Cpu, BarChart3, Video, Mic, CalendarDays, Users, Eye, Phone, type LucideIcon } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { useLang } from '@/context/LangContext'
@ -221,8 +221,17 @@ export default function Header() {
const [mobileOpen, setMobileOpen] = useState(false)
const [isMobile, setIsMobile] = useState(false)
const [searchOpen, setSearchOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const searchInputRef = useRef<HTMLInputElement>(null)
const navigate = useNavigate()
const runSearch = () => {
const q = searchQuery.trim()
if (!q) return
navigate(`/search?q=${encodeURIComponent(q)}`)
setSearchOpen(false)
setSearchQuery('')
}
const { lang, toggle: toggleLang } = useLang()
const { member } = useAuth()
@ -387,7 +396,12 @@ export default function Header() {
ref={searchInputRef} type="search"
placeholder="جستجو در گزارش‌ها، تحلیل‌ها، رویدادها..."
aria-label="جستجو"
onKeyDown={e => e.key === 'Escape' && setSearchOpen(false)}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Escape') setSearchOpen(false)
else if (e.key === 'Enter') runSearch()
}}
style={{
width: '100%', border: `1px solid ${T.ink}`, padding: '12px 16px',
fontSize: 14, background: T.paper, color: T.ink, outline: 'none',

View File

@ -6,9 +6,44 @@ import { ScrollTrigger } from 'gsap/ScrollTrigger'
import Header from './Header'
import { Footer } from './Footer'
import { LangProvider, useLang } from '@/context/LangContext'
import { useAuth, trackActivity } from '@/context/AuthContext'
gsap.registerPlugin(ScrollTrigger)
/* Activity tracker (logged-in members only)
Logs each page view on route change, and each click on a meaningful element
(links, buttons, or anything with data-track). The API is memberRequired, so
anonymous visitors are never recorded. */
function ActivityTracker() {
const { member } = useAuth()
const { pathname, search } = useLocation()
// page views
useEffect(() => {
if (!member) return
trackActivity('view', pathname + search)
}, [member, pathname, search])
// element clicks
useEffect(() => {
if (!member) return
const onClick = (e: MouseEvent) => {
const el = (e.target as Element)?.closest('a,button,[role="button"],[data-track]')
if (!el) return
const label =
el.getAttribute('data-track') ||
el.getAttribute('aria-label') ||
(el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 80) ||
el.tagName.toLowerCase()
trackActivity('click', window.location.pathname, label)
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}, [member])
return null
}
/* Persian-digit localizer
In fa, every Latin digit shown anywhere on the page is rendered as a
Persian digit (۰-۹). Skips inputs, code, SVG charts and [data-latin]. */
@ -106,6 +141,7 @@ export default function RootLayout() {
return (
<LangProvider>
<DigitLocalizer />
<ActivityTracker />
<div style={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column' }}>
<Header />
<main style={{ flex: 1 }}>

View File

@ -28,6 +28,7 @@ import ProfilePage from '@/pages/Profile/ProfilePage'
import Podcast from '@/pages/Podcast/Podcast'
import SpecializedMeetings from '@/pages/SpecializedMeetings/SpecializedMeetings'
import SteelDashboard from '@/pages/SteelDashboard/SteelDashboard'
import SearchResults from '@/pages/Search/SearchResults'
export const router = createBrowserRouter([
{
@ -41,6 +42,7 @@ export const router = createBrowserRouter([
{ path: 'events', element: <Events /> },
{ path: 'team', element: <Team /> },
{ path: 'archive', element: <Archive /> },
{ path: 'search', element: <SearchResults /> },
{ path: 'special', element: <Special /> },
{ path: 'risks', element: <Risks /> },
{ path: 'scanner', element: <Scanner /> },

View File

@ -42,6 +42,17 @@ function load(): AuthState {
return { member: null }
}
// localStorage throws "Access is denied" when the browser blocks site data
// (strict privacy / third-party-storage / sandboxed iframe). The cache is just
// a render optimization — the real session is the HttpOnly cookie — so writes
// must never break login. Swallow storage failures.
function cacheMember(m: Member | null) {
try {
if (m) localStorage.setItem('member_info', JSON.stringify(m))
else localStorage.removeItem('member_info')
} catch { /* storage blocked — session still lives in the cookie */ }
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<AuthState>(load)
@ -55,7 +66,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'خطا در ورود')
const member = toMember(data.user)
localStorage.setItem('member_info', JSON.stringify(member))
cacheMember(member)
setState({ member })
}, [])
@ -69,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'کد نامعتبر است')
const member = toMember(data.user)
localStorage.setItem('member_info', JSON.stringify(member))
cacheMember(member)
setState({ member })
}, [])
@ -83,7 +94,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'خطا در ثبت‌نام')
const member = toMember(data.user)
localStorage.setItem('member_info', JSON.stringify(member))
cacheMember(member)
setState({ member })
}, [])
@ -97,13 +108,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'خطا در ثبت‌نام')
const member = toMember(data.user)
localStorage.setItem('member_info', JSON.stringify(member))
cacheMember(member)
setState({ member })
}, [])
const logout = useCallback(() => {
fetch(`${BASE}/api/members/logout`, { method: 'POST', credentials: 'include' }).catch(() => {})
localStorage.removeItem('member_info')
cacheMember(null)
setState({ member: null })
}, [])
@ -111,7 +122,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setState(prev => {
if (!prev.member) return prev
const updated = { ...prev.member, ...data }
localStorage.setItem('member_info', JSON.stringify(updated))
cacheMember(updated)
return { ...prev, member: updated }
})
}, [])
@ -141,4 +152,19 @@ export function authFetch(path: string, opts: RequestInit = {}) {
})
}
// fire-and-forget activity beacon (logged-in members only; the API is memberRequired,
// so calls without a session cookie just 401 and are ignored). keepalive lets it
// survive a page navigation.
export function trackActivity(kind: 'view' | 'click', path: string, label?: string) {
try {
fetch(`${BASE}/api/members/activity`, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind, path, label }),
}).catch(() => {})
} catch { /* never let tracking break the app */ }
}
export { BASE }

View File

@ -288,7 +288,7 @@
/* scroll-behavior:smooth removed — it fights Lenis JS smoothing. Lenis owns scroll now. */
/* overflow-x: clip (not hidden) suppresses horizontal scroll WITHOUT making <html>
a scroll container, which would break the sticky header. */
html { direction: rtl; overflow-x: clip; }
html { direction: rtl; overflow-x: clip; touch-action: pan-x pan-y; -webkit-text-size-adjust: 100%; }
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
@ -349,8 +349,8 @@ body {
TICKER ANIMATION (RTL direction)
*/
@keyframes ticker-rtl {
0% { transform: translateX(50%); }
100% { transform: translateX(-50%); }
0% { transform: translateX(-50%); }
100% { transform: translateX(50%); }
}
.ticker-track { animation: ticker-rtl 34s linear infinite; white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .ticker-track { animation: none; } }

View File

@ -26,6 +26,16 @@ await Promise.all([
bootstrapFactoryReports(), bootstrapIntegrations(), bootstrapAbout(), bootstrapMedia(), bootstrapPosts(), bootstrapRadarFuture(),
])
// PWA: register service worker in production builds only (skip in dev — avoids stale-cache pain)
if ('serviceWorker' in navigator && import.meta.env.PROD) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {})
})
}
// iOS Safari ignores user-scalable=no — block pinch + double-tap zoom manually
document.addEventListener('gesturestart', (e) => e.preventDefault())
document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>

View File

@ -1,7 +1,7 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Link, useSearchParams } from 'react-router-dom'
import { reports } from '@/data/reports'
import type { ReportCategory, ReportType } from '@/data/reports'
import type { ReportType } from '@/data/reports'
/* ─── constants ──────────────────────────────────────────── */
const TYPE_LABELS: Record<ReportType, string> = {
@ -35,12 +35,6 @@ function parseDateGroup(dateStr: string): number {
return year * 100 + month
}
/* ─── unique categories from data ───────────────────────── */
const ALL_CATEGORIES: (ReportCategory | 'همه')[] = [
'همه',
...Array.from(new Set(reports.map((r) => r.category))) as ReportCategory[],
]
/* ─── price formatter ────────────────────────────────────── */
function formatPrice(price: number): string {
if (price === 0) return 'رایگان'
@ -161,8 +155,16 @@ function ReportRow({ report }: { report: (typeof reports)[0] }) {
/* ─── main component ─────────────────────────────────────── */
export default function Archive() {
const [search, setSearch] = useState('')
const [activeCategory, setActiveCategory] = useState<ReportCategory | 'همه'>('همه')
const [params] = useSearchParams()
const [search, setSearch] = useState(params.get('q') ?? '')
const [activeCategory, setActiveCategory] = useState<string>('همه')
// categories come from the live (bootstrapped) content, not the static fallback,
// so the chips always reflect what's actually published in the CMS.
const allCategories = useMemo(
() => ['همه', ...Array.from(new Set(reports.map((r) => r.category).filter(Boolean)))],
[],
)
const filtered = useMemo(() => {
let result = [...reports]
@ -173,12 +175,13 @@ export default function Archive() {
if (search.trim()) {
const q = search.trim().toLowerCase()
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(q)
result = result.filter(
(r) =>
r.title.toLowerCase().includes(q) ||
r.author.toLowerCase().includes(q) ||
r.category.toLowerCase().includes(q) ||
r.tags.some((t) => t.toLowerCase().includes(q))
has(r.title) ||
has(r.author) ||
has(r.category) ||
(r.tags ?? []).some((t) => has(t))
)
}
@ -285,12 +288,12 @@ export default function Archive() {
borderBottom: '1px solid var(--rule-thin)',
}}
>
{ALL_CATEGORIES.map((cat) => {
{allCategories.map((cat) => {
const isActive = activeCategory === cat
return (
<button
key={cat}
onClick={() => setActiveCategory(cat as ReportCategory | 'همه')}
onClick={() => setActiveCategory(cat)}
style={{
padding: '6px 16px',
fontSize: 12,

View File

@ -1,6 +1,6 @@
import { useState, useEffect, type CSSProperties } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import { X, Globe, Calendar, LayoutGrid, ExternalLink } from 'lucide-react'
import { X, Globe, Calendar, LayoutGrid, ExternalLink, Newspaper, LineChart, ChevronDown } from 'lucide-react'
import { useLang } from '@/context/LangContext'
const NAVY = '#032340'
@ -227,70 +227,93 @@ const NEWS_ITEMS = [
]
/* ─── modal ─── */
/* ─── modal — pixel-matched to Figma node 6791-15495 ─── */
const SectionHead = ({ Icon, children }: { Icon: typeof Newspaper; children: string }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Icon size={22} color="#0084E6" strokeWidth={1.6} />
<span style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: '28px' }}>{children}</span>
</div>
)
function CardModal({ card, lang, onClose }: { card: Card; lang: 'fa' | 'en'; onClose: () => void }) {
const isFa = lang === 'fa'
const dir = isFa ? 'rtl' : 'ltr'
const body = isFa ? card.bodyFa : card.bodyEn
const bodyText: CSSProperties = { fontSize: 14, lineHeight: '24px', color: '#676F71', textAlign: 'justify', margin: 0 }
const metaItem: CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#868686', lineHeight: '21px' }
return (
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(3,35,64,0.65)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px 16px' }}
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(3,35,64,0.55)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '20px 16px' }}
>
<motion.div
initial={{ opacity: 0, y: 32, scale: 0.96 }}
initial={{ opacity: 0, y: 32, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 32, scale: 0.96 }}
transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}
exit={{ opacity: 0, y: 32, scale: 0.97 }}
transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
onClick={e => e.stopPropagation()}
dir={dir}
style={{ background: '#faf7f4', borderRadius: 20, width: '100%', maxWidth: 680, maxHeight: '90vh', overflowY: 'auto', position: 'relative', boxShadow: '0 40px 100px -20px rgba(3,35,64,0.5)' }}
style={{ background: '#fff', border: '1px solid #E1E2E3', borderRadius: 16, width: '100%', maxWidth: 600, maxHeight: '92vh', overflowY: 'auto', position: 'relative', boxShadow: '0 24px 70px -16px rgba(0,0,0,0.32)' }}
>
{/* cover */}
<div style={{ position: 'relative', height: 260 }}>
<img src={card.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '20px 20px 0 0' }} />
<div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(3,35,64,0.55) 0%, transparent 50%)', borderRadius: '20px 20px 0 0' }} />
<div style={{ position: 'relative', height: 284, minHeight: 284 }}>
<img src={card.img} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<button onClick={onClose} style={{ position: 'absolute', top: 14, ...(isFa ? { left: 14 } : { right: 14 }), width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'rgba(3,35,64,0.55)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
<X size={16} />
</button>
</div>
{/* close */}
<button onClick={onClose} style={{ position: 'absolute', top: 14, ...(isFa ? { left: 14 } : { right: 14 }), width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'rgba(3,35,64,0.7)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
<X size={16} />
</button>
{/* content */}
<div style={{ padding: '16px 32px 0' }}>
{/* title row */}
<div style={{ borderBottom: '1px solid #E1E2E3', padding: '8px 0', marginBottom: 16 }}>
<h2 style={{ fontSize: 16, fontWeight: 700, color: '#151617', lineHeight: '28px', textAlign: isFa ? 'right' : 'left', margin: 0 }}>
{isFa ? card.titleFa : card.titleEn}
</h2>
</div>
{/* category badge (right) + meta (left) */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 20 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: '#fff', fontSize: 14, fontWeight: 500, lineHeight: '24px', padding: '0 8px', borderRadius: 3 }}>
<span>{catLabel(card.tab, isFa)}</span>
<LayoutGrid size={16} color="#fff" strokeWidth={1.6} />
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap' }}>
<span style={metaItem}><span>{card.source}</span><Globe size={18} color={GOLD} strokeWidth={1.6} /></span>
<span style={metaItem}><span>{isFa ? card.dateFa : card.dateEn}</span><Calendar size={18} color={GOLD} strokeWidth={1.6} /></span>
</div>
</div>
{/* اصل رویداد */}
<div style={{ marginBottom: 16 }}>
<SectionHead Icon={Newspaper}>{isFa ? 'اصل رویداد' : 'The Event'}</SectionHead>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{body.map((p, i) => <p key={i} style={bodyText}>{p}</p>)}
</div>
</div>
{/* دلالت‌های راهبردی */}
<div style={{ marginBottom: 16 }}>
<SectionHead Icon={LineChart}>{isFa ? 'دلالت‌های راهبردی' : 'Strategic Implications'}</SectionHead>
<p style={bodyText}>{isFa ? card.analysisFa : card.analysisEn}</p>
</div>
<div style={{ padding: '24px 28px 32px' }}>
{/* tags */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 20 }}>
{card.tags.map(t => (
<span key={t} style={{ fontSize: 10, fontWeight: 700, color: NAVY, background: `${GOLD}25`, borderRadius: 6, padding: '3px 10px' }}>{t}</span>
<span key={t} style={{ background: '#EDEDED', color: '#262626', fontSize: 12, lineHeight: '21px', padding: '4px 8px', borderRadius: 16 }}>#{''}{t.replace(/ /g, '_')}</span>
))}
</div>
</div>
<h2 style={{ fontSize: 'clamp(17px,2vw,22px)', fontWeight: 900, color: NAVY, lineHeight: 1.4, margin: '0 0 14px' }}>
{isFa ? card.titleFa : card.titleEn}
</h2>
{/* meta */}
<div style={{ display: 'flex', gap: 18, marginBottom: 20, direction: 'ltr', justifyContent: isFa ? 'flex-end' : 'flex-start' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'rgba(7,29,73,0.55)', fontWeight: 600 }}>
<Globe size={13} color={GOLD} />{card.source}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: 'rgba(7,29,73,0.55)', fontWeight: 600 }}>
<Calendar size={13} color={GOLD} />{isFa ? card.dateFa : card.dateEn}
</span>
</div>
{/* body */}
<div>
{body.map((p, i) => <p key={i} style={{ fontSize: 14, lineHeight: 2, color: 'rgba(7,29,73,0.8)', margin: '0 0 14px' }}>{p}</p>)}
</div>
{/* analysis */}
<div style={{ marginTop: 20, borderRadius: 12, padding: '16px 18px', background: `${NAVY}08`, border: `1.5px solid ${NAVY}15` }}>
<div style={{ fontSize: 10, fontWeight: 800, letterSpacing: '2px', color: GOLD, marginBottom: 8 }}>{isFa ? 'تحلیل راهبردی' : 'STRATEGIC ANALYSIS'}</div>
<p style={{ fontSize: 13, lineHeight: 1.9, color: NAVY, margin: 0 }}>{isFa ? card.analysisFa : card.analysisEn}</p>
</div>
{/* footer */}
<div style={{ background: NAVY, borderRadius: '0 0 16px 16px', padding: '16px 24px 24px', display: 'flex', alignItems: 'center', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: '#fff', border: 'none', borderRadius: 100, padding: '8px 16px', fontSize: 14, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}>
<X size={18} color="#fff" />
{isFa ? 'بستن' : 'Close'}
</button>
</div>
</motion.div>
</motion.div>
@ -370,8 +393,13 @@ export default function AtAGlance() {
return [...m.entries()].map(([k, n]) => ({ k, n }))
})()
// today's date — Jalali for fa (day number on the right, month on the left via dir=rtl)
const todayDate = new Date()
const todayFa = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { day: 'numeric', month: 'long' }).format(todayDate)
const todayEn = new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short' }).format(todayDate)
return (
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: '#faf7f4', minHeight: '100vh' }}>
<div dir={isFa ? 'rtl' : 'ltr'} style={{ background: 'var(--paper)', minHeight: '100vh' }}>
{/* ── breaking-news ticker ── */}
<div style={{ background: NAVY, color: '#fff' }}>
@ -389,15 +417,15 @@ export default function AtAGlance() {
))}
</div>
</div>
<span className="max-md:!hidden" style={{ fontSize: 12, color: GOLD, fontWeight: 700, flexShrink: 0, direction: 'ltr', whiteSpace: 'nowrap' }}>
{NEWS_ITEMS[0] ? (isFa ? NEWS_ITEMS[0].dateFa : NEWS_ITEMS[0].dateEn) : ''}
<span className="max-md:!hidden" style={{ fontSize: 12, color: GOLD, fontWeight: 700, flexShrink: 0, direction: 'rtl', whiteSpace: 'nowrap' }}>
{isFa ? todayFa : todayEn}
</span>
</div>
</div>
{/* ── heading ── */}
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingTop: 34, paddingBottom: 6 }}>
<div style={{ fontSize: 10, fontWeight: 700, letterSpacing: '3px', color: GOLD, marginBottom: 6 }}>{isFa ? 'دیده‌بانِ تحولات' : 'AT A GLANCE'}</div>
<div style={{ fontSize: 20, fontWeight: 800, letterSpacing: isFa ? '1px' : '3px', color: GOLD, marginBottom: 8 }}>{isFa ? 'دیده‌بانِ تحولات' : 'AT A GLANCE'}</div>
<h1 style={{ fontSize: 'clamp(24px,3vw,38px)', fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'در یک نگاه' : 'At a Glance'}</h1>
</div>
@ -437,31 +465,40 @@ export default function AtAGlance() {
{/* main news list */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 22, justifyContent: isFa ? 'flex-start' : 'flex-end', flexDirection: isFa ? 'row' : 'row-reverse' }}>
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحلیل‌ها' : 'Latest Analyses'}</h2>
<span style={{ width: 3, height: 26, background: GOLD, borderRadius: 2 }} />
<h2 style={{ fontSize: 20, fontWeight: 900, color: NAVY, margin: 0 }}>{isFa ? 'آخرین تحولات' : 'Latest Developments'}</h2>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{visibleList.map(card => (
<div key={card.id} onClick={() => setSelectedCard(card)} style={{ display: 'flex', gap: 14, background: '#ededed', borderRadius: 16, cursor: 'pointer', padding: 8, boxShadow: '0 4px 18px -12px rgba(3,35,64,0.3)' }} className="max-sm:!flex-col">
<img src={card.img} alt="" style={{ width: 200, height: 150, objectFit: 'cover', flexShrink: 0, borderRadius: 14 }} className="max-sm:!w-full max-sm:!h-44" />
<div style={{ padding: '8px 12px', textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
<Badge label={catLabel(card.tab, isFa)} />
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
<div style={{ position: 'relative' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{visibleList.map(card => (
<div key={card.id} onClick={() => setSelectedCard(card)} style={{ display: 'flex', gap: 14, background: '#ededed', borderRadius: 16, cursor: 'pointer', padding: 8, boxShadow: '0 4px 18px -12px rgba(3,35,64,0.3)' }} className="max-sm:!flex-col">
<img src={card.img} alt="" style={{ width: 200, height: 150, objectFit: 'cover', flexShrink: 0, borderRadius: 14 }} className="max-sm:!w-full max-sm:!h-44" />
<div style={{ padding: '8px 12px', textAlign: isFa ? 'right' : 'left', flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, marginBottom: 10 }}>
<Badge label={catLabel(card.tab, isFa)} />
<Meta source={card.source} date={isFa ? card.dateFa : card.dateEn} />
</div>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>
</div>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#262626', lineHeight: 1.8, margin: '0 0 8px' }}>{isFa ? card.titleFa : card.titleEn}</h3>
<p style={{ fontSize: 13, color: '#606060', lineHeight: 1.9, margin: 0, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{(isFa ? card.bodyFa : card.bodyEn)[0] || ''}</p>
</div>
</div>
))}
</div>
{!showAllList && listCards.length > 4 && (
<div style={{ textAlign: 'center', marginTop: 28 }}>
<button onClick={() => setShowAllList(true)} style={{ background: 'transparent', border: `1.5px solid ${GOLD}`, color: NAVY, borderRadius: 10, padding: '11px 36px', fontSize: 13.5, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit' }}>
{isFa ? 'مشاهده همه' : 'View all'}
</button>
))}
</div>
)}
{/* fade + blur the last card, float the CTA on top of it */}
{!showAllList && listCards.length > 4 && (
<div style={{ position: 'absolute', left: 0, right: 0, bottom: -28, height: 220, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 0, borderRadius: 16, pointerEvents: 'none', background: 'linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,0.5) 42%, rgba(255,255,255,0.9) 74%, #ffffff 92%)', backdropFilter: 'blur(2.5px)', WebkitBackdropFilter: 'blur(2.5px)' }}>
<button
onClick={() => setShowAllList(true)}
style={{ pointerEvents: 'auto', display: 'inline-flex', alignItems: 'center', gap: 8, background: GOLD, color: '#fff', border: 'none', borderRadius: 100, padding: '12px 38px', fontSize: 14, fontWeight: 800, cursor: 'pointer', fontFamily: 'inherit', boxShadow: '0 14px 30px -8px rgba(205,158,83,0.8)', transition: 'transform .18s, filter .18s' }}
onMouseEnter={e => { e.currentTarget.style.filter = 'brightness(1.08)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
onMouseLeave={e => { e.currentTarget.style.filter = 'none'; e.currentTarget.style.transform = 'none' }}
>
{isFa ? 'مشاهده همه' : 'View all'}
<ChevronDown size={18} />
</button>
</div>
)}
</div>
</div>
{/* sidebar */}
@ -487,7 +524,7 @@ export default function AtAGlance() {
{/* ── promo banner ── */}
<div className="max-w-7xl mx-auto px-12 max-md:px-5" style={{ paddingBottom: 76 }}>
<div style={{ position: 'relative', marginTop: 56 }}>
<div style={{ position: 'relative', marginTop: 152 }}>
<div style={{ position: 'relative', borderRadius: 16, overflow: 'hidden', background: `linear-gradient(160deg, #0a2f52 0%, ${NAVY} 55%, #021a30 100%)`, minHeight: 210, direction: 'rtl' }}>
{/* soft glow */}
<div aria-hidden style={{ position: 'absolute', top: -130, insetInlineEnd: '32%', width: 360, height: 340, borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />

View File

@ -310,6 +310,58 @@ function RegisterForm({ lang, navigate }: { lang: 'fa' | 'en'; navigate: (to?: s
)
}
/* ─── Login keyword animation ─── */
const KW_FA = ['آینده', 'فناوری', 'بازار', 'رقابت', 'نوآوری', 'پایداری', 'فولاد']
const KW_EN = ['Future', 'Technology', 'Market', 'Competition', 'Innovation', 'Sustainability', 'Steel']
// scattered positions (center点 + base size) — a word-cloud layout
const KW_POS = [
{ t: '12%', l: '26%', s: 3.3 }, // آینده
{ t: '25%', l: '72%', s: 2.6 }, // فناوری
{ t: '45%', l: '15%', s: 3.0 }, // بازار
{ t: '40%', l: '83%', s: 2.4 }, // رقابت
{ t: '78%', l: '24%', s: 2.8 }, // نوآوری
{ t: '83%', l: '66%', s: 2.5 }, // پایداری
{ t: '58%', l: '50%', s: 4.0 }, // فولاد
]
function KeywordCycle({ lang }: { lang: 'fa' | 'en' }) {
const words = lang === 'fa' ? KW_FA : KW_EN
const [active, setActive] = useState(0)
React.useEffect(() => {
const t = setInterval(() => setActive(p => (p + 1) % words.length), 1700)
return () => clearInterval(t)
}, [words.length])
// word cloud: all words scattered + dim; the selected one lights gold, scales and glows
return (
<div style={{ position: 'relative', zIndex: 1, width: '100%', height: 360 }}>
{words.map((w, idx) => {
const p = KW_POS[idx % KW_POS.length]
const isActive = idx === active
return (
<span
key={w}
style={{
position: 'absolute', top: p.t, left: p.l,
transform: `translate(-50%, -50%) scale(${isActive ? 1.35 : 1})`,
whiteSpace: 'nowrap',
fontSize: `${p.s}rem`,
fontWeight: isActive ? 900 : 700,
color: isActive ? GOLD : 'rgba(255,255,255,0.16)',
textShadow: isActive ? `0 0 26px ${GOLD}66` : 'none',
transition: 'color .55s ease, transform .55s cubic-bezier(0.22,1,0.36,1), text-shadow .55s',
zIndex: isActive ? 2 : 1,
willChange: 'transform',
}}
>
{w}
</span>
)
})}
</div>
)
}
/* ─── Brand Panel ─── */
function BrandPanel({ mode, lang }: { mode: Mode; lang: 'fa' | 'en' }) {
return (
@ -349,60 +401,47 @@ function BrandPanel({ mode, lang }: { mode: Mode; lang: 'fa' | 'en' }) {
</Link>
</div>
{mode === 'login' ? (
<>
<KeywordCycle lang={lang} />
<div />
</>
) : (
<>
{/* Center copy */}
<div style={{ position: 'relative', zIndex: 1, direction: 'rtl' }}>
<div style={{
display: 'inline-block', background: `${GOLD}22`, border: `1px solid ${GOLD}44`,
color: GOLD, padding: '0.3rem 0.85rem', borderRadius: 4,
fontSize: '0.72rem', fontWeight: 600, marginBottom: '1.1rem',
}}>
{mode === 'login'
? (lang === 'fa' ? 'پلتفرم تخصصی فولاد' : 'Steel Industry Platform')
: (lang === 'fa' ? 'عضویت رایگان' : 'Free Membership')}
</div>
<h1 style={{ color: '#fff', fontSize: 'clamp(1.5rem, 2.3vw, 2.2rem)', fontWeight: 800, lineHeight: 1.45, margin: '0 0 1rem' }}>
{mode === 'login'
? <>{lang === 'fa' ? 'دسترسی به تحلیل‌های تخصصی' : 'Access Expert Analysis'}<br/><span style={{ color: GOLD }}>{lang === 'fa' ? 'صنعت فولاد ایران' : 'Steel Industry'}</span></>
: <>{lang === 'fa' ? 'عضو اندیشکده شوید' : 'Join the Institute'}<br/><span style={{ color: GOLD }}>{lang === 'fa' ? 'و به تحلیل‌ها دسترسی داشته باشید' : 'Get Exclusive Access'}</span></>
}
{lang === 'fa' ? 'بینشی نو برای ' : 'A New Vision for '}<span style={{ color: GOLD, whiteSpace: 'nowrap' }}>{lang === 'fa' ? 'توسعه صنعتی' : 'Industrial Growth'}</span>
</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.9rem', lineHeight: 1.85, margin: 0, maxWidth: 320 }}>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.9rem', lineHeight: 1.85, margin: 0 }}>
{lang === 'fa'
? 'گزارش‌های بازار، رصد فناوری، و تحلیل ژئوپلیتیک — همه در یک پلتفرم'
: 'Market reports, technology intelligence, and geopolitical analysis — all in one platform'}
? 'رصد آینده‌پژوهانه بازار، فناوری، رقابت و سیاست‌های صنعت فولاد برای مدیران و تصمیم‌سازان.'
: 'Forward-looking monitoring of markets, technology, competition and steel-industry policy for managers and decision-makers.'}
</p>
</div>
{/* Bottom stats / checklist */}
<div style={{ position: 'relative', zIndex: 1, direction: 'rtl' }}>
{mode === 'login'
? (
<div style={{ display: 'flex', gap: '2rem' }}>
{[['+۲۰۰', lang === 'fa' ? 'گزارش' : 'Reports'], ['۵۰+', lang === 'fa' ? 'کارشناس' : 'Experts'], ['۳ سال', lang === 'fa' ? 'سابقه' : 'History']].map(([n, l]) => (
<div key={l as string}>
<div style={{ color: GOLD, fontSize: '1.35rem', fontWeight: 800 }}>{n}</div>
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '0.72rem', marginTop: 2 }}>{l}</div>
</div>
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{[
lang === 'fa' ? 'دسترسی به گزارش‌های تخصصی' : 'Access to expert reports',
lang === 'fa' ? 'رصد بازار و قیمت‌ها' : 'Market and price monitoring',
lang === 'fa' ? 'شبکه خبرگان صنعت' : 'Industry expert network',
].map(item => (
<div key={item} style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.65)', fontSize: '0.85rem' }}>
<div style={{ width: 20, height: 20, borderRadius: '50%', background: `${GOLD}30`, border: `1px solid ${GOLD}55`, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={GOLD} strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</div>
{item}
</div>
))}
</div>
)
}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.85rem' }}>
<div style={{ color: GOLD, fontSize: '0.95rem', fontWeight: 800 }}>{lang === 'fa' ? 'آینده‌پژوهی صنعت فولاد' : 'Steel Industry Foresight'}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{[
lang === 'fa' ? 'رصد فناوری‌های نوظهور' : 'Emerging technology monitoring',
lang === 'fa' ? 'هوشمندی رقابتی' : 'Competitive intelligence',
lang === 'fa' ? 'سناریونگاری آینده' : 'Future scenario planning',
lang === 'fa' ? 'تحلیل‌های تخصصی بازار' : 'Expert market analysis',
lang === 'fa' ? 'شبکه خبرگان صنعت' : 'Industry expert network',
].map(item => (
<div key={item} style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.72)', fontSize: '0.875rem' }}>
<span style={{ color: GOLD, fontSize: '0.7rem', flexShrink: 0, lineHeight: 1 }}></span>
{item}
</div>
))}
</div>
</div>
</div>
</>
)}
</div>
)
}

View File

@ -0,0 +1,115 @@
import { useMemo } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useLang } from '@/context/LangContext'
import { reports } from '@/data/reports'
import { POSTS } from '@/content/posts'
import { RADAR_ITEMS, RADAR_CATEGORY_LABEL } from '@/content/radar'
import { RADAR_FUTURE } from '@/content/radarFuture'
import { videos, podcasts } from '@/data/media'
import { events } from '@/data/events'
const GOLD = '#CD9E53'
const NAVY = '#032340'
type Result = { id: string; title: string; summary: string; meta: string; badge: string; color: string; url: string }
/* Aggregate every searchable content source into one flat, normalized list.
All of these are already bootstrapped from the panel into memory at startup,
so this is a pure client-side search no extra API call. */
function buildIndex(isFa: boolean): Result[] {
const out: Result[] = []
const L = (o: { fa: string; en: string }) => (isFa ? o.fa : o.en)
for (const r of reports) {
out.push({ id: `report-${r.id}`, title: r.title, summary: r.summary || '', meta: r.category || '', badge: 'گزارش', color: '#1d4ed8', url: `/reports/${r.id}` })
}
for (const p of POSTS) {
const t = isFa ? p.fa : p.en
out.push({ id: `post-${p.id}`, title: t.title, summary: t.lead || '', meta: L(p.category), badge: 'مطلب', color: '#9b1c1c', url: `/posts/${p.id}` })
}
for (const it of RADAR_ITEMS) {
const t = isFa ? it.fa : it.en
out.push({ id: `glance-${it.id}`, title: t.title, summary: t.excerpt || '', meta: L(RADAR_CATEGORY_LABEL[it.category]), badge: 'در یک نگاه', color: GOLD, url: `/radar/post/${it.id}` })
}
for (const it of RADAR_FUTURE) {
const t = isFa ? it.fa : it.en
out.push({ id: `radar-${it.id}`, title: t.title, summary: t.excerpt || '', meta: it.category || '', badge: 'رادار آینده', color: '#166534', url: `/radar/post/${it.id}` })
}
for (const v of videos) {
const t = isFa ? v.fa : v.en
out.push({ id: `video-${v.id}`, title: t.title, summary: t.body || '', meta: t.guest || '', badge: 'ویدیوکست', color: '#4a1d96', url: '/podcast' })
}
for (const p of podcasts) {
const t = isFa ? p.fa : p.en
out.push({ id: `podcast-${p.id}`, title: t.title, summary: t.body || '', meta: t.ep || '', badge: 'پادکست', color: '#7c2d12', url: '/podcast' })
}
for (const e of events) {
out.push({ id: `event-${e.id}`, title: e.title, summary: '', meta: '', badge: 'رویداد', color: '#0f766e', url: '/events' })
}
return out
}
export default function SearchResults() {
const { lang } = useLang()
const isFa = lang === 'fa'
const [params] = useSearchParams()
const q = (params.get('q') || '').trim()
const results = useMemo(() => {
if (!q) return []
const needle = q.toLowerCase()
const has = (v: unknown) => String(v ?? '').toLowerCase().includes(needle)
return buildIndex(isFa).filter((r) => has(r.title) || has(r.summary) || has(r.meta))
}, [q, isFa])
return (
<div dir={isFa ? 'rtl' : 'ltr'} style={{ maxWidth: 1000, margin: '0 auto', padding: 'clamp(24px,5vw,56px) clamp(16px,4vw,40px)', minHeight: '60vh' }}>
<div style={{ borderBottom: `3px solid ${NAVY}`, paddingBottom: 14, marginBottom: 28 }}>
<h1 style={{ fontSize: 24, fontWeight: 900, color: NAVY, margin: 0 }}>
{isFa ? 'نتایج جستجو' : 'Search results'}
</h1>
{q && (
<p style={{ fontSize: 14, color: 'var(--ink-4)', margin: '8px 0 0' }}>
{isFa
? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${q}»`
: `${results.length} results for “${q}`}
</p>
)}
</div>
{!q ? (
<p style={{ color: 'var(--ink-5)', fontSize: 15 }}>{isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}</p>
) : results.length === 0 ? (
<div style={{ padding: '64px 0', textAlign: 'center', color: 'var(--ink-5)' }}>
<p style={{ fontSize: 15 }}>{isFa ? 'نتیجه‌ای یافت نشد' : 'No results found'}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{results.map((r) => (
<Link
key={r.id}
to={r.url}
style={{
display: 'block', textDecoration: 'none', border: '1px solid var(--rule-thin)',
borderRadius: 12, padding: '16px 18px', background: '#fff', transition: 'box-shadow 150ms, transform 150ms',
}}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 8px 24px rgba(3,35,64,0.10)'; e.currentTarget.style.transform = 'translateY(-2px)' }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.transform = 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, fontWeight: 700, color: '#fff', background: r.color, borderRadius: 6, padding: '2px 8px' }}>{r.badge}</span>
{r.meta && <span style={{ fontSize: 11, color: 'var(--ink-5)' }}>{r.meta}</span>}
</div>
<h3 style={{ fontSize: 16, fontWeight: 800, color: NAVY, margin: '0 0 4px' }}>{r.title}</h3>
{r.summary && (
<p style={{ fontSize: 13, color: 'var(--ink-4)', margin: 0, lineHeight: 1.7, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{r.summary}
</p>
)}
</Link>
))}
</div>
)}
</div>
)
}