آخرین ورود: ${fmtDateTime(m.lastLogin)} · عضویت: ${fmtDateTime(m.createdAt)}${m.purchases ? ` · ${m.purchases} خرید` : ''}
+کاربران سایت
+کاربران ثبتنامی سایت (موبایل/کد یکبارمصرف). آخرین ورود، شماره تماس، مسدودسازی و حذف.
+ +در حال بارگذاری…
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', '↻ بروزرسانی')}
+ کاربران ثبتنامی سایت (موبایل/کد یکبارمصرف). آخرین ورود، شماره تماس، مسدودسازی و حذف. در حال بارگذاری…کاربران سایت
+
کاربری یافت نشد.
`; return; } + $('#list').innerHTML = items.map(m => ` +آخرین ورود: ${fmtDateTime(m.lastLogin)} · عضویت: ${fmtDateTime(m.createdAt)}${m.purchases ? ` · ${m.purchases} خرید` : ''}
+خطا در بارگذاری کاربران.
`; + } +} + +async function renderMemberDetail(id) { + root.innerHTML = ` +در حال بارگذاری…
در حال بارگذاری…
فعالیتی ثبت نشده.
`; + } else { + $('#activity').innerHTML = `خطا در بارگذاری فعالیت.
`; } + } catch (err) { + $('#detail').innerHTML = `خطا در بارگذاری.
`; + } +} + +// ═══════════════════════════════════════════════ +// NEWSLETTER SUBSCRIBERS +// ═══════════════════════════════════════════════ +async function renderNewsletterList() { + root.innerHTML = ` + ${topbarHtml('newsletter', '↻ بروزرسانی')} +ایمیلهایی که از فرم فوتر سایت ثبت شدهاند.
+در حال بارگذاری…
هنوز ایمیلی ثبت نشده.
`; return; } + $('#list').innerHTML = `${items.length.toLocaleString('fa-IR')} ایمیل
` + + items.map(s => ` +خطا در بارگذاری.
`; + } +} + // ═══════════════════════════════════════════════ // 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 (