diff --git a/index.html b/index.html index 6307580..3212c36 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,11 @@ - + + + + + diff --git a/panel/db.js b/panel/db.js index 32e7598..1cb00d7 100644 --- a/panel/db.js +++ b/panel/db.js @@ -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) { diff --git a/panel/public/app.js b/panel/public/app.js index a6001b5..dd60ac3 100644 --- a/panel/public/app.js +++ b/panel/public/app.js @@ -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', '↻ بروزرسانی')} +
+

کاربران سایت

+

کاربران ثبت‌نامی سایت (موبایل/کد یکبارمصرف). آخرین ورود، شماره تماس، مسدودسازی و حذف.

+ +

در حال بارگذاری…

+
+ `; + wireTabs(); + $('#newBtn').addEventListener('click', () => renderMembersList()); + + let all = []; + function paint(items) { + if (!items.length) { $('#list').innerHTML = `

کاربری یافت نشد.

`; return; } + $('#list').innerHTML = items.map(m => ` +
+
+
+ ${escapeHtml(m.name || 'بی‌نام')} + ${m.isActive ? '' : 'مسدود'} + ${m.phone ? `${escapeHtml(m.phone)}` : 'بدون شماره'} +
+

آخرین ورود: ${fmtDateTime(m.lastLogin)} · عضویت: ${fmtDateTime(m.createdAt)}${m.purchases ? ` · ${m.purchases} خرید` : ''}

+
+
+ + + +
+
+ `).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 = `

خطا در بارگذاری کاربران.

`; + } +} + +async function renderMemberDetail(id) { + root.innerHTML = ` +
+
جزئیات کاربر
+
+
+

در حال بارگذاری…

+ `; + $('#backBtn').addEventListener('click', () => renderMembersList()); + try { + const m = await api(`/api/members/${id}`); + const field = (label, val, ltr) => ` +
+ ${label} + ${val} +
`; + const buys = m.purchases?.length + ? m.purchases.map(p => `
  • ${escapeHtml(p.title || p.articleId)} — ${fmtDateTime(p.purchasedAt)}${p.amount ? ` (${p.amount.toLocaleString('fa-IR')} تومان)` : ''}
  • `).join('') + : '
  • خریدی ثبت نشده
  • '; + $('#detail').innerHTML = ` +
    +

    ${escapeHtml(m.name || 'بی‌نام')} ${m.isActive ? '' : 'مسدود'}

    + ${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 ? 'فعال' : 'مسدود')} +

    خریدها (${m.purchases?.length || 0})

    + +

    فعالیت اخیر

    +

    در حال بارگذاری…

    +
    + + +
    +
    `; + $('#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 = `

    فعالیتی ثبت نشده.

    `; + } else { + $('#activity').innerHTML = ``; + } + } catch { $('#activity').innerHTML = `

    خطا در بارگذاری فعالیت.

    `; } + } catch (err) { + $('#detail').innerHTML = `

    خطا در بارگذاری.

    `; + } +} + +// ═══════════════════════════════════════════════ +// NEWSLETTER SUBSCRIBERS +// ═══════════════════════════════════════════════ +async function renderNewsletterList() { + root.innerHTML = ` + ${topbarHtml('newsletter', '↻ بروزرسانی')} +
    +

    عضویت در خبرنامه

    +

    ایمیل‌هایی که از فرم فوتر سایت ثبت شده‌اند.

    +

    در حال بارگذاری…

    +
    + `; + wireTabs(); + $('#newBtn').addEventListener('click', () => renderNewsletterList()); + + try { + const items = await api('/api/newsletter'); + if (!items.length) { $('#list').innerHTML = `

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

    `; return; } + $('#list').innerHTML = `

    ${items.length.toLocaleString('fa-IR')} ایمیل

    ` + + items.map(s => ` +
    +
    +
    + ${escapeHtml(s.email)} + ${fmtDateTime(s.createdAt)} +
    +
    +
    +
    `).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 = `

    خطا در بارگذاری.

    `; + } +} + // ═══════════════════════════════════════════════ // EVENTS // ═══════════════════════════════════════════════ diff --git a/panel/public/style.css b/panel/public/style.css index 7d477a7..b8b924c 100644 --- a/panel/public/style.css +++ b/panel/public/style.css @@ -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; } } diff --git a/panel/server.js b/panel/server.js index 2b483f1..542301e 100644 --- a/panel/server.js +++ b/panel/server.js @@ -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); diff --git a/public/site.webmanifest b/public/site.webmanifest index d4f984b..689e22d 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -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"}]} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..e80c012 --- /dev/null +++ b/public/sw.js @@ -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('/'))) + ) +}) diff --git a/src/app/layout/Footer.tsx b/src/app/layout/Footer.tsx index b9f0dba..a84fbff 100644 --- a/src/app/layout/Footer.tsx +++ b/src/app/layout/Footer.tsx @@ -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 (
    عضویت در خبرنامه اندیشکده
    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() { }} />
    + {err &&
    {err}
    }
    ) } diff --git a/src/app/layout/Header.tsx b/src/app/layout/Header.tsx index d4f3865..800e1e9 100644 --- a/src/app/layout/Header.tsx +++ b/src/app/layout/Header.tsx @@ -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(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', diff --git a/src/app/layout/RootLayout.tsx b/src/app/layout/RootLayout.tsx index 61a8d7c..bc561e6 100644 --- a/src/app/layout/RootLayout.tsx +++ b/src/app/layout/RootLayout.tsx @@ -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 ( +
    diff --git a/src/app/router.tsx b/src/app/router.tsx index 6a6dcbf..32cb78d 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -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: }, { path: 'team', element: }, { path: 'archive', element: }, + { path: 'search', element: }, { path: 'special', element: }, { path: 'risks', element: }, { path: 'scanner', element: }, diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index 4f5e86c..fd1bd9a 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -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(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 } diff --git a/src/index.css b/src/index.css index 39926fb..2f74d94 100644 --- a/src/index.css +++ b/src/index.css @@ -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 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; } } diff --git a/src/main.tsx b/src/main.tsx index f8c84c5..247803c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/pages/Archive/Archive.tsx b/src/pages/Archive/Archive.tsx index 6449363..6e09840 100644 --- a/src/pages/Archive/Archive.tsx +++ b/src/pages/Archive/Archive.tsx @@ -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 = { @@ -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('همه') + const [params] = useSearchParams() + const [search, setSearch] = useState(params.get('q') ?? '') + const [activeCategory, setActiveCategory] = useState('همه') + + // 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 (
    - {/* close */} - + {/* content */} +
    + {/* title row */} +
    +

    + {isFa ? card.titleFa : card.titleEn} +

    +
    + + {/* category badge (right) + meta (left) */} +
    + + {catLabel(card.tab, isFa)} + + +
    + {card.source} + {isFa ? card.dateFa : card.dateEn} +
    +
    + + {/* اصل رویداد */} +
    + {isFa ? 'اصل رویداد' : 'The Event'} +
    + {body.map((p, i) =>

    {p}

    )} +
    +
    + + {/* دلالت‌های راهبردی */} +
    + {isFa ? 'دلالت‌های راهبردی' : 'Strategic Implications'} +

    {isFa ? card.analysisFa : card.analysisEn}

    +
    -
    {/* tags */} -
    +
    {card.tags.map(t => ( - {t} + #{'‌'}{t.replace(/ /g, '_')} ))}
    +
    -

    - {isFa ? card.titleFa : card.titleEn} -

    - - {/* meta */} -
    - - {card.source} - - - {isFa ? card.dateFa : card.dateEn} - -
    - - {/* body */} -
    - {body.map((p, i) =>

    {p}

    )} -
    - - {/* analysis */} -
    -
    {isFa ? 'تحلیل راهبردی' : 'STRATEGIC ANALYSIS'}
    -

    {isFa ? card.analysisFa : card.analysisEn}

    -
    + {/* footer */} +
    +
    @@ -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 ( -
    +
    {/* ── breaking-news ticker ── */}
    @@ -389,15 +417,15 @@ export default function AtAGlance() { ))}
    - - {NEWS_ITEMS[0] ? (isFa ? NEWS_ITEMS[0].dateFa : NEWS_ITEMS[0].dateEn) : ''} + + {isFa ? todayFa : todayEn}
    {/* ── heading ── */}
    -
    {isFa ? 'دیده‌بانِ تحولات' : 'AT A GLANCE'}
    +
    {isFa ? 'دیده‌بانِ تحولات' : 'AT A GLANCE'}

    {isFa ? 'در یک نگاه' : 'At a Glance'}

    @@ -437,31 +465,40 @@ export default function AtAGlance() { {/* main news list */}
    -

    {isFa ? 'آخرین تحلیل‌ها' : 'Latest Analyses'}

    +

    {isFa ? 'آخرین تحولات' : 'Latest Developments'}

    -
    - {visibleList.map(card => ( -
    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"> - -
    -
    - - +
    +
    + {visibleList.map(card => ( +
    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"> + +
    +
    + + +
    +

    {isFa ? card.titleFa : card.titleEn}

    +

    {(isFa ? card.bodyFa : card.bodyEn)[0] || ''}

    -

    {isFa ? card.titleFa : card.titleEn}

    -

    {(isFa ? card.bodyFa : card.bodyEn)[0] || ''}

    -
    - ))} -
    - {!showAllList && listCards.length > 4 && ( -
    - + ))}
    - )} + {/* fade + blur the last card, float the CTA on top of it */} + {!showAllList && listCards.length > 4 && ( +
    + +
    + )} +
    {/* sidebar */} @@ -487,7 +524,7 @@ export default function AtAGlance() { {/* ── promo banner ── */}
    -
    +
    {/* soft glow */}
    diff --git a/src/pages/Auth/AuthPage.tsx b/src/pages/Auth/AuthPage.tsx index 952f6f4..1d3e843 100644 --- a/src/pages/Auth/AuthPage.tsx +++ b/src/pages/Auth/AuthPage.tsx @@ -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 ( +
    + {words.map((w, idx) => { + const p = KW_POS[idx % KW_POS.length] + const isActive = idx === active + return ( + + {w} + + ) + })} +
    + ) +} + /* ─── 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' }) {
    + {mode === 'login' ? ( + <> + +
    + + ) : ( + <> {/* Center copy */}
    -
    - {mode === 'login' - ? (lang === 'fa' ? 'پلتفرم تخصصی فولاد' : 'Steel Industry Platform') - : (lang === 'fa' ? 'عضویت رایگان' : 'Free Membership')} -

    - {mode === 'login' - ? <>{lang === 'fa' ? 'دسترسی به تحلیل‌های تخصصی' : 'Access Expert Analysis'}
    {lang === 'fa' ? 'صنعت فولاد ایران' : 'Steel Industry'} - : <>{lang === 'fa' ? 'عضو اندیشکده شوید' : 'Join the Institute'}
    {lang === 'fa' ? 'و به تحلیل‌ها دسترسی داشته باشید' : 'Get Exclusive Access'} - } + {lang === 'fa' ? 'بینشی نو برای ' : 'A New Vision for '}{lang === 'fa' ? 'توسعه صنعتی' : 'Industrial Growth'}

    -

    +

    {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.'}

    {/* Bottom stats / checklist */}
    - {mode === 'login' - ? ( -
    - {[['+۲۰۰', lang === 'fa' ? 'گزارش' : 'Reports'], ['۵۰+', lang === 'fa' ? 'کارشناس' : 'Experts'], ['۳ سال', lang === 'fa' ? 'سابقه' : 'History']].map(([n, l]) => ( -
    -
    {n}
    -
    {l}
    -
    - ))} -
    - ) : ( -
    - {[ - lang === 'fa' ? 'دسترسی به گزارش‌های تخصصی' : 'Access to expert reports', - lang === 'fa' ? 'رصد بازار و قیمت‌ها' : 'Market and price monitoring', - lang === 'fa' ? 'شبکه خبرگان صنعت' : 'Industry expert network', - ].map(item => ( -
    -
    - -
    - {item} -
    - ))} -
    - ) - } +
    +
    {lang === 'fa' ? 'آینده‌پژوهی صنعت فولاد' : 'Steel Industry Foresight'}
    +
    + {[ + 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 => ( +
    + + {item} +
    + ))} +
    +
    + + )}
    ) } diff --git a/src/pages/Search/SearchResults.tsx b/src/pages/Search/SearchResults.tsx new file mode 100644 index 0000000..e8f8306 --- /dev/null +++ b/src/pages/Search/SearchResults.tsx @@ -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 ( +
    +
    +

    + {isFa ? 'نتایج جستجو' : 'Search results'} +

    + {q && ( +

    + {isFa + ? `${results.length.toLocaleString('fa-IR')} نتیجه برای «${q}»` + : `${results.length} results for “${q}”`} +

    + )} +
    + + {!q ? ( +

    {isFa ? 'عبارتی برای جستجو وارد کنید.' : 'Enter a search term.'}

    + ) : results.length === 0 ? ( +
    +

    {isFa ? 'نتیجه‌ای یافت نشد' : 'No results found'}

    +
    + ) : ( +
    + {results.map((r) => ( + { 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' }} + > +
    + {r.badge} + {r.meta && {r.meta}} +
    +

    {r.title}

    + {r.summary && ( +

    + {r.summary} +

    + )} + + ))} +
    + )} +
    + ) +}