Files
teknav-bak/app/teknav-profile.jsx
T
arsam 112d1b5330 Import source: app/teknav-profile
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:26:26 +03:30

1016 lines
53 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// User profile page — /profile/@username
// Terracotta / warm-orange / unique-green palette; no teal.
import { useState, useEffect, useRef } from 'react';
import { useNav, useAuth, useToast, EmptyState, pillBtn, inputStyle } from './teknav-ui.jsx';
import { api } from './src/lib/api.js';
import { engagementApi, pushApi, notificationPreferencesApi } from './src/lib/engagement-api.js';
import { TeknavData } from './teknav-data.js';
import { iconNode } from './src/lib/icon.jsx';
import eyeIcon from './src/assets/icons/eye.svg';
import gradCapIcon from './src/assets/icons/graduation-cap.svg';
import scrollIcon from './src/assets/icons/scroll.svg';
import botIcon from './src/assets/icons/bot.svg';
import chartIcon from './src/assets/icons/chart-column.svg';
import lockIcon from './src/assets/icons/lock.svg';
import laptopIcon from './src/assets/icons/laptop.svg';
import flameIcon from './src/assets/icons/flame.svg';
import zapIcon from './src/assets/icons/zap.svg';
import libraryIcon from './src/assets/icons/library.svg';
import sproutIcon from './src/assets/icons/sprout.svg';
import medalIcon from './src/assets/icons/medal.svg';
// ── Theme ────────────────────────────────────────────────────────────────────
const P = {
terracotta: '#C45C3D',
orange: '#E07A3A',
green: '#3A7D5E',
greenLight: '#E8F4EF',
ink: '#263238',
muted: '#5F6B6D',
cream: '#FAF7F0',
panel: '#F4EFE6',
line: '#E4DDD2',
white: '#fff',
};
const ROLE_LABELS = {
admin: { label: 'ادمین', bg: 'rgba(196,92,61,0.10)', color: '#C45C3D', border: 'rgba(196,92,61,0.22)' },
editor: { label: 'سردبیر', bg: 'rgba(58,125,94,0.10)', color: '#2F8F6B', border: 'rgba(58,125,94,0.22)' },
writer: { label: 'نویسنده', bg: 'rgba(212,154,42,0.10)', color: '#A87A10', border: 'rgba(212,154,42,0.22)' },
reviewer: { label: 'بازبین', bg: 'rgba(100,120,200,0.10)', color: '#5566AA', border: 'rgba(100,120,200,0.22)' },
reader: { label: 'خواننده', bg: 'rgba(95,107,109,0.08)', color: '#5F6B6D', border: 'rgba(95,107,109,0.18)' },
};
function RolePill({ role }) {
const r = ROLE_LABELS[role] ?? ROLE_LABELS.reader;
return (
<span style={{
display: 'inline-block', padding: '3px 12px', borderRadius: 20,
fontSize: 11, fontWeight: 700, letterSpacing: 0.4,
background: r.bg, color: r.color, border: `1px solid ${r.border}`,
}}>{r.label}</span>
);
}
function findStaticAuthor(slug) {
return TeknavData.authors.find(author => author.username === slug || author.slug === slug);
}
function getPublishedAuthorArticles(author) {
return TeknavData.articles
.filter(article => (
(article.authorId === author.id || article.authorName === author.name || article.authorUsername === author.username)
&& article.status === 'منتشرشده'
))
.map(article => ({
id: article.id,
slug: article.slug,
title: article.title,
summary: article.summary,
publishedAt: article.dateEn ? `${article.dateEn}T09:00:00+03:30` : null,
views: article.views ?? 0,
reactions: article.reactions ?? 0,
}));
}
function buildStaticAuthorProfile(author) {
if (!author) return null;
const authorArticles = getPublishedAuthorArticles(author);
return {
id: author.id,
username: author.username,
name: author.name,
bio: author.bio,
avatarUrl: author.avatarUrl ?? null,
role: 'writer',
createdAt: author.joinedDate ? new Date(author.joinedDate).toISOString() : null,
author: {
articles: authorArticles,
articleCount: authorArticles.length,
verifiedExpert: author.verifiedExpert ?? false,
verificationNote: author.verificationNote ?? author.specialty,
},
_static: true,
expertise: author.expertise ?? [],
education: author.education ?? null,
alumniOf: author.alumniOf ?? null,
credential: author.credential ?? null,
social: author.social ?? {},
specialty: author.specialty,
};
}
function mergeProfileWithAuthor(profileData, author) {
if (!profileData || !author) return profileData;
return {
...profileData,
expertise: profileData.expertise || author.expertise || [],
education: profileData.education || author.education || null,
alumniOf: profileData.alumniOf || author.alumniOf || null,
credential: profileData.credential || author.credential || null,
avatarUrl: profileData.avatarUrl || author.avatarUrl || null,
social: { ...author.social, ...profileData.social },
specialty: profileData.specialty || author.specialty,
author: {
...profileData.author,
verifiedExpert: profileData.author?.verifiedExpert ?? author.verifiedExpert ?? false,
verificationNote: profileData.author?.verificationNote ?? author.verificationNote ?? author.specialty,
}
};
}
// ── Avatar with upload overlay ───────────────────────────────────────────────
function AvatarUpload({ user, editable, onUploaded }) {
const inputRef = useRef(null);
const [busy, setBusy] = useState(false);
const [webp, setWebp] = useState(true);
const [preview, setPreview] = useState(user.avatarUrl ?? null);
useEffect(() => {
setPreview(user.avatarUrl ?? null);
}, [user.avatarUrl]);
const handleFile = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024) { alert('حجم تصویر نباید بیشتر از ۵ مگابایت باشد.'); return; }
setBusy(true);
try {
const dataUrl = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.onerror = rej;
fr.readAsDataURL(file);
});
const base64 = dataUrl.split(',')[1];
const data = await api.post('/api/auth/avatar', { file: base64, mimeType: file.type, webp });
setPreview(data.url);
onUploaded?.(data.url);
} catch {
alert('آپلود آواتار ناموفق بود. دوباره تلاش کنید.');
} finally {
setBusy(false);
}
};
const initials = user.name ? user.name.slice(0, 2) : '؟';
const avatarStyle = {
width: 110, height: 110, borderRadius: '50%',
border: `3px solid rgba(196,92,61,0.35)`,
boxShadow: '0 4px 20px rgba(196,92,61,0.15)',
objectFit: 'cover', display: 'block',
};
return (
<div style={{ position: 'relative', width: 110 }}>
{preview
? <img src={preview} alt={user.name} width="110" height="110" loading="eager" decoding="async" fetchPriority="high" style={avatarStyle} />
: (
<div style={{ ...avatarStyle, background: P.terracotta, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, fontWeight: 900, color: '#fff' }}>
{initials}
</div>
)
}
{editable && (
<>
<button
disabled={busy}
onClick={() => inputRef.current?.click()}
style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
background: 'rgba(0,0,0,0.55)', color: '#fff', border: 'none', borderRadius: '0 0 55px 55px',
fontSize: 11, padding: '6px 0', cursor: 'pointer',
}}
>{busy ? '…' : 'تغییر'}</button>
<input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleFile} />
<label style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 8, fontSize: 11, color: P.muted, cursor: 'pointer', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={webp} onChange={e => setWebp(e.target.checked)} style={{ accentColor: P.terracotta }} />
ذخیره WebP
</label>
</>
)}
</div>
);
}
// ── Inline edit field ────────────────────────────────────────────────────────
function EditField({ label, value, onChange, multiline, placeholder, maxLength }) {
const style = {
...inputStyle,
fontSize: 14, padding: '8px 12px', marginTop: 4, resize: multiline ? 'vertical' : 'none',
minHeight: multiline ? 80 : undefined,
};
return (
<label style={{ display: 'block', marginBottom: 14 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: P.muted, marginBottom: 4 }}>{label}</div>
{multiline
? <textarea value={value} onChange={e => onChange(e.target.value)} style={style} dir="rtl" placeholder={placeholder} maxLength={maxLength} />
: <input type="text" value={value} onChange={e => onChange(e.target.value)} style={style} dir="auto" placeholder={placeholder} maxLength={maxLength} />
}
</label>
);
}
// ── Article mini-card ────────────────────────────────────────────────────────
function ArticleMiniCard({ article, onClick }) {
const date = article.publishedAt ? new Date(article.publishedAt).toLocaleDateString('fa-IR') : '';
return (
<div
onClick={onClick}
style={{
background: P.white, border: `1px solid ${P.line}`, borderRadius: 12, padding: '16px 20px',
cursor: 'pointer', direction: 'rtl', transition: 'all 0.2s',
borderRight: `4px solid ${P.terracotta}`,
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-2px)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.07)'; }}
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
>
<div style={{ fontSize: 15, fontWeight: 700, color: P.ink, marginBottom: 6, lineHeight: 1.5 }}>{article.title}</div>
{article.summary && <div style={{ fontSize: 13, color: P.muted, lineHeight: 1.7, marginBottom: 10 }}>{article.summary.slice(0, 140)}{article.summary.length > 140 ? '…' : ''}</div>}
<div style={{ display: 'flex', gap: 14, fontSize: 11, color: P.muted }}>
{date && <span>{date}</span>}
<span><span className="tek-icon" style={{ ['--i']: `url("${eyeIcon}")` }} aria-hidden="true" /> {article.views ?? 0}</span>
<span>♥ {article.reactions ?? 0}</span>
</div>
</div>
);
}
// ── Profile settings modal ───────────────────────────────────────────────────
function ProfileSettingsModal({ user, account, onClose, onSaved }) {
const toast = useToast();
const { refreshMe } = useAuth();
const [form, setForm] = useState({ name: user.name ?? '', username: user.username ?? '', bio: user.bio ?? '' });
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', password: '', confirm: '' });
const [phone, setPhone] = useState(account?.phone ?? '');
const [otp, setOtp] = useState('');
const [busy, setBusy] = useState(false);
const [verifyBusy, setVerifyBusy] = useState(false);
const [error, setError] = useState('');
const [pushEnabled, setPushEnabled] = useState(false);
const [pushBlocked, setPushBlocked] = useState(false);
const [pushBusy, setPushBusy] = useState(false);
const canEditUsername = user?.role !== 'reader';
useEffect(() => {
if (!('PushManager' in window) || !('serviceWorker' in navigator)) return;
if (typeof Notification !== 'undefined' && Notification.permission === 'denied') {
setPushBlocked(true);
return;
}
pushApi.isSubscribed().then(setPushEnabled).catch(() => {});
}, []);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const setPasswordField = (k, v) => setPasswordForm(f => ({ ...f, [k]: v }));
const save = async () => {
setBusy(true); setError('');
try {
const username = form.username.trim().toLowerCase().replace(/^@/, '');
if (canEditUsername && !/^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/.test(username)) {
setError('نام کاربری باید ۳ تا ۳۰ کاراکتر لاتین، عدد، نقطه یا زیرخط باشد.');
return;
}
if (passwordForm.password || passwordForm.confirm || passwordForm.currentPassword) {
if (passwordForm.password.length < 8) {
setError('رمز عبور جدید باید حداقل ۸ کاراکتر باشد.');
return;
}
if (passwordForm.password !== passwordForm.confirm) {
setError('تکرار رمز عبور جدید تطابق ندارد.');
return;
}
}
const data = await api.put('/api/auth/profile', {
name: form.name || undefined,
...(canEditUsername ? { username } : {}),
bio: form.bio || undefined,
...(passwordForm.password ? { currentPassword: passwordForm.currentPassword, password: passwordForm.password } : {}),
});
onSaved?.(data.user);
await refreshMe?.();
onClose();
} catch (e) {
const code = e.body?.error;
setError(code === 'username_taken'
? 'این نام کاربری قبلاً گرفته شده است.'
: code === 'current_password_required'
? 'برای تغییر رمز عبور، رمز فعلی را وارد کنید.'
: code === 'current_password_invalid'
? 'رمز فعلی درست نیست.'
: code === 'username_change_forbidden'
? 'تغییر نام کاربری برای این حساب مجاز نیست.'
: 'ذخیره ناموفق بود.');
} finally {
setBusy(false);
}
};
const requestEmailVerification = async () => {
setVerifyBusy(true);
try {
const res = await api.post('/api/auth/email/verify/request', {});
toast(res?.alreadyVerified ? 'ایمیل قبلاً تایید شده است' : 'لینک تایید ایمیل ارسال شد');
await refreshMe?.();
} catch {
toast('ارسال لینک تایید ایمیل ناموفق بود', 'error');
} finally {
setVerifyBusy(false);
}
};
const sendOtp = async () => {
setVerifyBusy(true);
try {
await api.post('/api/auth/otp/send', { phone });
toast('کد تایید پیامک شد');
} catch (e) {
toast(e.body?.error === 'sms_not_configured' ? 'درگاه پیامک روی سرور تنظیم نشده است' : 'ارسال کد تایید ناموفق بود', 'error');
} finally {
setVerifyBusy(false);
}
};
const verifyOtp = async () => {
setVerifyBusy(true);
try {
await api.post('/api/auth/otp/verify', { phone, code: otp });
await refreshMe?.();
toast('شماره موبایل تایید شد');
} catch {
toast('کد تایید معتبر نیست', 'error');
} finally {
setVerifyBusy(false);
}
};
const toggleTwoFactor = async () => {
setVerifyBusy(true);
try {
const next = !account?.twoFactorEnabled;
const res = await api.put('/api/auth/2fa', { enabled: next });
await refreshMe?.();
onSaved?.(res.user);
toast(next ? 'ورود دومرحله‌ای فعال شد' : 'ورود دومرحله‌ای غیرفعال شد');
} catch (e) {
toast(e.body?.error === 'phone_required' ? 'برای فعال‌سازی، ابتدا موبایل را تایید کنید' : 'تغییر ورود دومرحله‌ای ناموفق بود', 'error');
} finally {
setVerifyBusy(false);
}
};
return (
<div style={{ position: 'fixed', inset: 0, zIndex: 600, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div onClick={onClose} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.5)' }} />
<div style={{
position: 'relative', background: P.cream, borderRadius: 16, padding: 32,
maxWidth: 440, width: '90%', maxHeight: '88vh', overflowY: 'auto', direction: 'rtl', boxShadow: '0 24px 64px rgba(0,0,0,0.22)',
}}>
<h3 style={{ margin: '0 0 24px', color: P.ink, fontSize: 18 }}>ویرایش پروفایل</h3>
<EditField label="نام نمایشی" value={form.name} onChange={v => set('name', v)} placeholder="نام کامل شما" maxLength={80} />
{canEditUsername ? (
<EditField label="نام کاربری (@ آدرس)" value={form.username} onChange={v => set('username', v.toLowerCase())} placeholder="coaxys" maxLength={30} />
) : (
<div style={{ fontSize: 12, color: P.muted, marginBottom: 14, background: P.panel, border: `1px solid ${P.line}`, borderRadius: 10, padding: '8px 10px' }}>
نام کاربری برای کاربران عادی قابل تغییر نیست.
</div>
)}
<EditField label="بیوگرافی" value={form.bio} onChange={v => set('bio', v)} multiline placeholder="چند جمله درباره خودتان…" maxLength={280} />
<div style={{ background: P.panel, border: `1px solid ${P.line}`, borderRadius: 12, padding: 14, marginBottom: 14 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: P.ink, marginBottom: 10 }}>تغییر رمز عبور</div>
<div style={{ display: 'grid', gap: 10 }}>
<input value={passwordForm.currentPassword} onChange={e => setPasswordField('currentPassword', e.target.value)} type="password" placeholder="رمز فعلی" style={{ ...inputStyle, direction: 'ltr', textAlign: 'left' }} autoComplete="current-password" />
<input value={passwordForm.password} onChange={e => setPasswordField('password', e.target.value)} type="password" placeholder="رمز جدید" style={{ ...inputStyle, direction: 'ltr', textAlign: 'left' }} autoComplete="new-password" />
<input value={passwordForm.confirm} onChange={e => setPasswordField('confirm', e.target.value)} type="password" placeholder="تکرار رمز جدید" style={{ ...inputStyle, direction: 'ltr', textAlign: 'left' }} autoComplete="new-password" />
</div>
</div>
<div style={{ background: P.panel, border: `1px solid ${P.line}`, borderRadius: 12, padding: 14, marginBottom: 14 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: P.ink, marginBottom: 10 }}>تایید حساب</div>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, color: P.muted }}>ایمیل: {account?.emailVerified ? 'تایید شده' : 'تایید نشده'}</span>
{!account?.emailVerified && <button onClick={requestEmailVerification} disabled={verifyBusy} style={{ ...pillBtn, background: '#fff', color: P.terracotta, border: `1px solid ${P.line}`, fontSize: 12 }}>ارسال لینک تایید</button>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8 }}>
<input value={phone} onChange={e => setPhone(e.target.value)} placeholder="شماره موبایل" style={inputStyle} dir="ltr" />
<button onClick={sendOtp} disabled={verifyBusy || !phone} style={{ ...pillBtn, background: '#fff', color: P.green, border: `1px solid ${P.line}`, fontSize: 12 }}>ارسال کد</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8 }}>
<input value={otp} onChange={e => setOtp(e.target.value)} placeholder="کد تایید" style={inputStyle} dir="ltr" />
<button onClick={verifyOtp} disabled={verifyBusy || !phone || !otp} style={{ ...pillBtn, background: P.green, color: '#fff', fontSize: 12 }}>تایید موبایل</button>
</div>
<div style={{ fontSize: 11, color: P.muted }}>وضعیت موبایل: {account?.phoneVerified ? 'تایید شده' : 'تایید نشده'}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center', flexWrap: 'wrap', borderTop: `1px solid ${P.line}`, paddingTop: 10 }}>
<span style={{ fontSize: 12, color: P.muted }}>ورود دومرحله‌ای: {account?.twoFactorEnabled ? 'فعال' : 'غیرفعال'}</span>
<button onClick={toggleTwoFactor} disabled={verifyBusy || (!account?.twoFactorEnabled && !account?.phoneVerified)} style={{ ...pillBtn, background: account?.twoFactorEnabled ? '#fff' : P.green, color: account?.twoFactorEnabled ? P.terracotta : '#fff', border: `1px solid ${P.line}`, fontSize: 12 }}>
{account?.twoFactorEnabled ? 'غیرفعال‌سازی' : 'فعال‌سازی SMS'}
</button>
</div>
</div>
</div>
{'PushManager' in window && (
<div style={{ background: P.panel, border: `1px solid ${P.line}`, borderRadius: 12, padding: 14, marginBottom: 14 }}>
<div style={{ fontSize: 13, fontWeight: 800, color: P.ink, marginBottom: 10 }}>اعلان‌های مرورگر</div>
{pushBlocked ? (
<div style={{ fontSize: 12, color: P.muted }}>مرورگر شما اجازه اعلان را مسدود کرده است. برای فعال‌سازی، از تنظیمات مرورگر اجازه دهید.</div>
) : (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 12, color: P.muted }}>
اعلان‌های مقالات جدید و نظرات: {pushEnabled ? 'فعال' : 'غیرفعال'}
</span>
<button
disabled={pushBusy}
onClick={async () => {
setPushBusy(true);
try {
if (pushEnabled) {
await pushApi.unsubscribe();
setPushEnabled(false);
toast('اعلان‌های مرورگر غیرفعال شد');
} else {
const perm = typeof Notification !== 'undefined' ? await Notification.requestPermission() : 'denied';
if (perm === 'granted') {
await pushApi.subscribe();
setPushEnabled(true);
toast('اعلان‌های مرورگر فعال شد');
} else {
setPushBlocked(true);
}
}
} catch { toast('عملیات ناموفق بود', 'error'); }
finally { setPushBusy(false); }
}}
style={{ ...pillBtn, background: pushEnabled ? '#fff' : P.green, color: pushEnabled ? P.terracotta : '#fff', border: `1px solid ${P.line}`, fontSize: 12, opacity: pushBusy ? 0.6 : 1 }}
>
{pushBusy ? '…' : pushEnabled ? 'غیرفعال‌سازی' : 'فعال‌سازی'}
</button>
</div>
)}
</div>
)}
<NotificationPreferencesPanel />
{error && <div style={{ fontSize: 13, color: '#C94C4C', marginBottom: 12 }}>{error}</div>}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 4 }}>
<button onClick={onClose} style={{ ...pillBtn, background: P.panel, color: P.ink, border: `1px solid ${P.line}`, padding: '9px 20px' }}>انصراف</button>
<button onClick={save} disabled={busy} style={{ ...pillBtn, background: P.terracotta, color: '#fff', padding: '9px 20px', opacity: busy ? 0.6 : 1 }}>{busy ? '…' : 'ذخیره'}</button>
</div>
</div>
</div>
);
}
function NotificationPreferencesPanel() {
const toast = useToast();
const EVENT_LABELS = {
comment: 'نظر جدید روی مقاله',
comment_reply: 'پاسخ به نظر',
new_article: 'مقاله جدید از نویسندگان دنبال‌شده',
review_approved: 'تأیید مقاله',
review_revision: 'بازخورد ویراستار',
review_rejected: 'رد مقاله',
review_submitted: 'ارسال مقاله برای بررسی',
system: 'پیام‌های سیستمی',
};
const CHANNEL_LABELS = { push: 'مرورگر', email: 'ایمیل', sms: 'پیامک' };
const CHANNELS = ['push', 'email', 'sms'];
const [prefs, setPrefs] = useState(null);
const [saving, setSaving] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open) return;
notificationPreferencesApi.get().then((data) => setPrefs(data.preferences)).catch(() => {});
}, [open]);
const toggle = async (eventType, channel) => {
if (!prefs) return;
const updated = prefs.map((p) =>
p.eventType === eventType && p.channel === channel ? { ...p, enabled: !p.enabled } : p,
);
setPrefs(updated);
setSaving(true);
try {
await notificationPreferencesApi.update(updated);
} catch {
toast('ذخیره تنظیمات ناموفق بود', 'error');
setPrefs(prefs); // revert
} finally {
setSaving(false);
}
};
const getEnabled = (eventType, channel) =>
prefs?.find((p) => p.eventType === eventType && p.channel === channel)?.enabled ?? true;
return (
<div style={{ background: P.panel, border: `1px solid ${P.line}`, borderRadius: 12, padding: 14, marginBottom: 14 }}>
<button
onClick={() => setOpen((o) => !o)}
style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
>
<span style={{ fontSize: 13, fontWeight: 800, color: P.ink }}>تنظیمات اعلان‌ها</span>
<span style={{ fontSize: 12, color: P.muted }}>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div style={{ marginTop: 12, overflowX: 'auto' }}>
{prefs === null ? (
<div style={{ fontSize: 12, color: P.muted }}>در حال بارگذاری…</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead>
<tr>
<th style={{ textAlign: 'right', paddingLeft: 8, paddingBottom: 8, color: P.muted, fontWeight: 600 }}>رویداد</th>
{CHANNELS.map((ch) => (
<th key={ch} style={{ textAlign: 'center', paddingBottom: 8, color: P.muted, fontWeight: 600, minWidth: 48 }}>{CHANNEL_LABELS[ch]}</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(EVENT_LABELS).map(([evt, label]) => (
<tr key={evt} style={{ borderTop: `1px solid ${P.line}` }}>
<td style={{ paddingTop: 6, paddingBottom: 6, color: P.ink, paddingLeft: 8 }}>{label}</td>
{CHANNELS.map((ch) => (
<td key={ch} style={{ textAlign: 'center', paddingTop: 6, paddingBottom: 6 }}>
<button
disabled={saving}
onClick={() => toggle(evt, ch)}
style={{
width: 28, height: 16, borderRadius: 8, border: 'none', cursor: saving ? 'default' : 'pointer',
background: getEnabled(evt, ch) ? P.green : '#CCC',
opacity: saving ? 0.6 : 1, transition: 'background 0.2s',
}}
title={getEnabled(evt, ch) ? 'فعال — برای غیرفعال‌سازی کلیک کنید' : 'غیرفعال — برای فعال‌سازی کلیک کنید'}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
)}
</div>
)}
</div>
);
}
// ── Inline SVG sparkline ─────────────────────────────────────────────────────
function Sparkline({ data, color = '#C45C3D', width = 120, height = 36 }) {
if (!data || data.length < 2) return <svg width={width} height={height} />;
const vals = data.map(d => d.views ?? 0);
const max = Math.max(...vals, 1);
const pts = vals.map((v, i) => {
const x = (i / (vals.length - 1)) * width;
const y = height - (v / max) * (height - 4) - 2;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
<polyline points={pts} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
<polyline points={`0,${height} ${pts} ${width},${height}`} fill={color} fillOpacity="0.12" stroke="none" />
</svg>
);
}
// ── Writer analytics tab ──────────────────────────────────────────────────────
function WriterAnalyticsTab() {
const [overview, setOverview] = useState(null);
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
engagementApi.writerAnalyticsOverview().catch(() => null),
engagementApi.writerAnalyticsArticles().catch(() => ({ items: [] })),
]).then(([ov, arts]) => {
if (!cancelled) {
setOverview(ov);
setArticles(arts?.items ?? []);
setLoading(false);
}
});
return () => { cancelled = true; };
}, []);
if (loading) return <div style={{ padding: 24, color: P.muted, fontSize: 14 }}>در حال بارگذاری…</div>;
const totalViews = overview?.views ?? 0;
const totalReactions = overview?.reactions ?? 0;
const totalComments = overview?.comments ?? 0;
const viewsByDay = overview?.viewsByDay ?? [];
return (
<div style={{ direction: 'rtl' }}>
{/* Summary cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 14, marginBottom: 28 }}>
{[
{ label: 'بازدید کل', value: totalViews.toLocaleString('fa-IR'), color: P.terracotta },
{ label: 'واکنش‌ها', value: totalReactions.toLocaleString('fa-IR'), color: P.orange },
{ label: 'نظرات', value: totalComments.toLocaleString('fa-IR'), color: P.green },
].map(({ label, value, color }) => (
<div key={label} style={{ background: P.white, border: `1px solid ${P.line}`, borderRadius: 12, padding: 16, borderTop: `3px solid ${color}` }}>
<div style={{ fontSize: 22, fontWeight: 900, color }}>{value}</div>
<div style={{ fontSize: 12, color: P.muted, marginTop: 4 }}>{label}</div>
</div>
))}
</div>
{/* 30-day views sparkline */}
{viewsByDay.length > 1 && (
<div style={{ background: P.white, border: `1px solid ${P.line}`, borderRadius: 12, padding: 18, marginBottom: 24 }}>
<div style={{ fontSize: 13, fontWeight: 700, color: P.ink, marginBottom: 12 }}>بازدید ۳۰ روز اخیر</div>
<Sparkline data={viewsByDay} color={P.terracotta} width={500} height={56} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: P.muted, marginTop: 4 }}>
<span>{viewsByDay[0]?.date?.slice(5) ?? ''}</span>
<span>{viewsByDay[viewsByDay.length - 1]?.date?.slice(5) ?? ''}</span>
</div>
</div>
)}
{/* Per-article table */}
{articles.length > 0 && (
<div style={{ background: P.white, border: `1px solid ${P.line}`, borderRadius: 12, overflow: 'hidden' }}>
<div style={{ padding: '14px 18px', borderBottom: `1px solid ${P.line}`, fontSize: 13, fontWeight: 700, color: P.ink }}>عملکرد مقاله‌ها</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, fontFamily: 'Vazirmatn,sans-serif' }}>
<thead>
<tr style={{ background: P.panel }}>
{['عنوان', 'بازدید', 'واکنش', 'نظر', 'ذخیره'].map(h => (
<th key={h} style={{ padding: '8px 14px', textAlign: 'right', color: P.muted, fontWeight: 600, whiteSpace: 'nowrap' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{articles.map((a, i) => (
<tr key={a.id} style={{ borderTop: `1px solid ${P.line}`, background: i % 2 === 0 ? P.white : P.cream }}>
<td style={{ padding: '8px 14px', color: P.ink, maxWidth: 260, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{a.title}</td>
<td style={{ padding: '8px 14px', color: P.terracotta, fontWeight: 700 }}>{(a.views ?? 0).toLocaleString('fa-IR')}</td>
<td style={{ padding: '8px 14px', color: P.orange }}>{(a.reactions ?? 0).toLocaleString('fa-IR')}</td>
<td style={{ padding: '8px 14px', color: P.muted }}>{(a.comments ?? 0).toLocaleString('fa-IR')}</td>
<td style={{ padding: '8px 14px', color: P.muted }}>{(a.saved ?? 0).toLocaleString('fa-IR')}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{articles.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 0', color: P.muted, fontSize: 14 }}>هنوز داده‌ای برای نمایش وجود ندارد.</div>
)}
</div>
);
}
// ── Main profile page ─────────────────────────────────────────────────────────
export function UserProfilePage({ username }) {
const { navigate } = useNav();
const { user: me } = useAuth();
const slug = username.replace(/^@/, '');
const [profile, setProfile] = useState(() => {
const author = findStaticAuthor(slug);
return buildStaticAuthorProfile(author);
});
const [loading, setLoading] = useState(() => !findStaticAuthor(slug));
const [showSettings, setShowSettings] = useState(false);
const [followers, setFollowers] = useState({ count: 0, following: false });
const [history, setHistory] = useState([]);
const [following, setFollowing] = useState([]);
const [activeTab, setActiveTab] = useState('articles');
const [badges, setBadges] = useState([]);
const isOwn = me && (me.username === slug || me.id === profile?.id);
useEffect(() => {
let cancelled = false;
const author = findStaticAuthor(slug);
const staticProfile = buildStaticAuthorProfile(author);
if (staticProfile) {
setProfile(staticProfile);
setLoading(false);
} else {
setProfile(null);
setLoading(true);
}
api.get(`/api/profile/${slug}`)
.then(d => {
if (cancelled) return;
const profileData = d.profile;
setProfile(profileData ? mergeProfileWithAuthor(profileData, author) : staticProfile);
})
.catch(() => {
if (cancelled) return;
if (!staticProfile) {
setProfile(null);
}
})
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [slug]);
useEffect(() => {
let cancelled = false;
if (!profile?.username || profile._static || !['writer', 'editor', 'admin'].includes(profile.role)) return;
engagementApi.writerFollowers(profile.username)
.then(res => { if (!cancelled) setFollowers({ count: res?.count ?? 0, following: !!res?.following }); })
.catch(() => {});
return () => { cancelled = true; };
}, [profile?.username, profile?.role]);
useEffect(() => {
let cancelled = false;
if (!isOwn) return;
Promise.all([
engagementApi.listHistory({ limit: 20 }).catch(() => ({ items: [] })),
engagementApi.following().catch(() => ({ items: [] })),
engagementApi.getBadges().catch(() => ({ badges: [] })),
]).then(([historyRes, followingRes, badgesRes]) => {
if (!cancelled) {
setHistory(historyRes?.items ?? []);
setFollowing(followingRes?.items ?? []);
setBadges(badgesRes?.badges ?? []);
}
});
return () => { cancelled = true; };
}, [isOwn]);
if (loading) {
return (
<div style={{ paddingTop: 120, textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', color: P.muted }}>در حال بارگذاری…</div>
);
}
if (!profile) {
return <EmptyState title="پروفایل یافت نشد" subtitle={`@${slug} وجود ندارد یا هنوز نام کاربری تنظیم نکرده.`} />;
}
const articles = profile.author?.articles ?? [];
const articleCount = profile.author?.liveArticleCount ?? profile.author?.articleCount ?? articles.length;
const showArticles = ['writer', 'editor', 'admin'].includes(profile.role) && articles.length > 0;
const joinDate = profile.createdAt ? new Date(profile.createdAt).toLocaleDateString('fa-IR', { year: 'numeric', month: 'long' }) : '';
const canFollow = me && !isOwn && ['writer', 'editor', 'admin'].includes(profile.role);
const toggleFollow = async () => {
try {
const res = followers.following
? await engagementApi.unfollowWriter(profile.username)
: await engagementApi.followWriter(profile.username);
setFollowers({ count: res.count ?? followers.count, following: !!res.following });
} catch {
/* follow state remains unchanged */
}
};
return (
<div style={{ paddingTop: 80, background: P.cream, minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
{/* Hero banner */}
<div style={{
background: `linear-gradient(145deg, #FDF6EE 0%, #F7EDE0 55%, #EEF5F0 100%)`,
borderBottom: `1px solid ${P.line}`,
padding: '52px 24px 44px',
position: 'relative',
overflow: 'hidden',
}}>
{/* Decorative shapes */}
<div style={{ position: 'absolute', top: -50, right: -50, width: 240, height: 240, borderRadius: '50%', background: 'rgba(196,92,61,0.06)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -30, left: 60, width: 160, height: 160, borderRadius: '50%', background: 'rgba(58,125,94,0.07)', pointerEvents: 'none' }} />
<div style={{ position: 'absolute', top: 30, left: '40%', width: 80, height: 80, borderRadius: '50%', background: 'rgba(212,154,42,0.06)', pointerEvents: 'none' }} />
<div style={{ maxWidth: 900, margin: '0 auto', display: 'flex', gap: 32, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<AvatarUpload
user={profile}
editable={!!isOwn}
onUploaded={url => setProfile(p => ({ ...p, avatarUrl: url }))}
/>
<div style={{ flex: 1, minWidth: 200 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, flexWrap: 'wrap' }}>
<h1 style={{ fontSize: 26, fontWeight: 900, color: P.ink, margin: 0, lineHeight: 1.3, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{profile.name}
{profile.author?.verifiedExpert && <span style={{ fontSize: 11, color: P.terracotta, background: 'rgba(196,92,61,0.08)', border: `1px solid rgba(196,92,61,0.22)`, borderRadius: 999, padding: '2px 9px' }}>متخصص تاییدشده</span>}
</h1>
<RolePill role={profile.role} />
</div>
<div style={{ fontSize: 13, color: P.terracotta, fontWeight: 600, marginBottom: 8 }}>@{profile.username}</div>
{profile.author?.verifiedExpert && profile.author?.verificationNote && (
<div style={{ fontSize: 12, color: P.green, fontWeight: 600, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: P.green, flexShrink: 0 }} />
{profile.author.verificationNote}
</div>
)}
{profile.bio && <p style={{ fontSize: 14, color: P.muted, lineHeight: 1.85, margin: '0 0 14px', maxWidth: 580 }}>{profile.bio}</p>}
{(profile.alumniOf || profile.credential) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 14, background: 'rgba(255,255,255,0.6)', padding: '10px 14px', borderRadius: 8, border: `1px solid ${P.line}`, maxWidth: 580 }}>
{profile.alumniOf && (
<div style={{ fontSize: 13, color: P.ink, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}><span className="tek-icon" style={{ ['--i']: `url("${gradCapIcon}")` }} aria-hidden="true" /></span>
<span>دانش‌آموخته: <strong>{profile.alumniOf.name}</strong></span>
{profile.alumniOf.sameAs && (
<a href={profile.alumniOf.sameAs} target="_blank" rel="noopener noreferrer" style={{ fontSize: 11, color: P.terracotta, textDecoration: 'none', background: 'rgba(196,92,61,0.06)', padding: '1px 6px', borderRadius: 4 }}>تاییدیه نهاد</a>
)}
</div>
)}
{profile.credential && (
<div style={{ fontSize: 13, color: P.ink, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}><span className="tek-icon" style={{ ['--i']: `url("${scrollIcon}")` }} aria-hidden="true" /></span>
<span>درجه علمی: <strong>{profile.credential.name}</strong></span>
</div>
)}
</div>
)}
{profile.expertise?.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14 }}>
{profile.expertise.map(tag => (
<span key={tag} style={{ fontSize: 11, padding: '3px 11px', borderRadius: 20, background: P.greenLight, color: P.green, border: `1px solid rgba(58,125,94,0.18)`, fontWeight: 500 }}>{tag}</span>
))}
</div>
)}
<div style={{ display: 'flex', gap: 18, flexWrap: 'wrap', fontSize: 12, color: P.muted, alignItems: 'center' }}>
{joinDate && <span>عضو از {joinDate}</span>}
{showArticles && <span style={{ color: P.terracotta, fontWeight: 700 }}>{articleCount} مقاله</span>}
{['writer', 'editor', 'admin'].includes(profile.role) && !profile._static && <span>{followers.count.toLocaleString('fa-IR')} دنبال‌کننده</span>}
{profile.social?.twitter && profile.social.twitter !== '#' && (
<a href={profile.social.twitter} target="_blank" rel="noopener noreferrer" style={{ color: P.muted, textDecoration: 'none', fontWeight: 700, fontSize: 13 }}>𝕏</a>
)}
{profile.social?.linkedin && profile.social.linkedin !== '#' && (
<a href={profile.social.linkedin} target="_blank" rel="noopener noreferrer" style={{ color: P.muted, textDecoration: 'none', fontWeight: 700, fontSize: 13 }}>in</a>
)}
</div>
</div>
{canFollow && (
<button
onClick={toggleFollow}
style={{
...pillBtn, background: followers.following ? P.greenLight : 'rgba(196,92,61,0.08)',
color: followers.following ? P.green : P.terracotta,
border: `1px solid ${followers.following ? 'rgba(58,125,94,0.28)' : 'rgba(196,92,61,0.25)'}`, padding: '9px 20px', fontSize: 13,
alignSelf: 'flex-start',
}}
>{followers.following ? 'دنبال می‌کنی' : 'دنبال کردن'}</button>
)}
{isOwn && (
<button
onClick={() => setShowSettings(true)}
style={{
...pillBtn, background: 'rgba(196,92,61,0.08)', color: P.terracotta,
border: `1px solid rgba(196,92,61,0.25)`, padding: '9px 20px', fontSize: 13,
alignSelf: 'flex-start',
}}
>ویرایش پروفایل</button>
)}
</div>
</div>
{/* Stats bar (for writers/editors) */}
{showArticles && (
<div style={{ background: P.panel, borderBottom: `1px solid ${P.line}` }}>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '0 24px', display: 'flex', gap: 0 }}>
{[
{ label: 'مقالات منتشرشده', value: articleCount },
{ label: 'مجموع بازدید', value: articles.reduce((s, a) => s + (a.views ?? 0), 0).toLocaleString('fa-IR') },
{ label: 'مجموع واکنش', value: articles.reduce((s, a) => s + (a.reactions ?? 0), 0).toLocaleString('fa-IR') },
].map(({ label, value }) => (
<div key={label} style={{ padding: '18px 32px 18px 0', flex: 1, borderLeft: `1px solid ${P.line}`, lastChild: { borderLeft: 'none' } }}>
<div style={{ fontSize: 22, fontWeight: 900, color: P.terracotta }}>{value}</div>
<div style={{ fontSize: 11, color: P.muted, marginTop: 2 }}>{label}</div>
</div>
))}
</div>
</div>
)}
{/* Earned badges */}
{badges.length > 0 && (
<div style={{ background: P.white, borderBottom: `1px solid ${P.line}` }}>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '20px 24px' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: P.ink, marginBottom: 12 }}>نشان‌های کسب‌شده</div>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap' }}>
{badges.map((b) => {
const META = {
ai_reader: { icon: botIcon, label: 'خواننده AI' },
data_reader: { icon: chartIcon, label: 'خواننده داده' },
security_reader: { icon: lockIcon, label: 'خواننده امنیت' },
hardware_reader: { icon: laptopIcon, label: 'خواننده سخت‌افزار' },
weekly_streak: { icon: flameIcon, label: 'رکورد هفتگی' },
monthly_streak: { icon: zapIcon, label: 'رکورد ماهانه' },
power_reader: { icon: libraryIcon, label: 'خواننده حرفه‌ای' },
early_adopter: { icon: sproutIcon, label: 'اولین کاربران' },
};
const m = META[b.badgeType] || { icon: medalIcon, label: b.badgeType };
const earned = b.earnedAt ? new Date(b.earnedAt).toLocaleDateString('fa-IR', { year: 'numeric', month: 'long' }) : '';
return (
<div key={b.id} title={earned} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'linear-gradient(135deg,#F4EFE6,#FDFAF5)',
border: `1px solid ${P.line}`, borderRadius: 24,
padding: '6px 14px', fontSize: 12, fontWeight: 600, color: P.ink,
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
}}>
<span style={{ fontSize: 16 }}>{iconNode(m.icon)}</span>
<span>{m.label}</span>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Article list */}
<div style={{ maxWidth: 900, margin: '0 auto', padding: '32px 24px' }}>
{isOwn && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 28 }} className="profile-own-grid">
<div style={{ background: P.white, border: `1px solid ${P.line}`, borderRadius: 12, padding: 18 }}>
<h2 style={{ fontSize: 15, fontWeight: 800, color: P.ink, margin: '0 0 14px' }}>تاریخچه خواندن</h2>
{history.length === 0 ? <div style={{ fontSize: 13, color: P.muted }}>هنوز مقاله‌ای در تاریخچه شما ثبت نشده است.</div> : history.slice(0, 5).map(item => (
<button key={item.id} onClick={() => navigate('/article/' + item.article.slug)} style={{ display: 'block', width: '100%', background: 'transparent', border: 'none', borderBottom: `1px solid ${P.line}`, padding: '10px 0', textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: P.ink }}>{item.article.title}</div>
<div style={{ fontSize: 11, color: P.muted, marginTop: 2 }}>{item.time}</div>
</button>
))}
{history.length > 0 && <button onClick={async () => { if (confirm('تاریخچه خواندن پاک شود؟')) { await engagementApi.clearHistory(); setHistory([]); } }} style={{ ...pillBtn, background: '#C94C4C18', color: '#C94C4C', fontSize: 12, marginTop: 12 }}>پاک کردن تاریخچه</button>}
</div>
<div style={{ background: P.white, border: `1px solid ${P.line}`, borderRadius: 12, padding: 18 }}>
<h2 style={{ fontSize: 15, fontWeight: 800, color: P.ink, margin: '0 0 14px' }}>نویسندگان دنبال‌شده</h2>
{following.length === 0 ? <div style={{ fontSize: 13, color: P.muted }}>هنوز نویسنده‌ای را دنبال نمی‌کنید.</div> : following.slice(0, 6).map(writer => (
<button key={writer.id} onClick={() => navigate('/profile/@' + writer.username)} style={{ display: 'block', width: '100%', background: 'transparent', border: 'none', borderBottom: `1px solid ${P.line}`, padding: '10px 0', textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
<div style={{ fontSize: 13, fontWeight: 700, color: P.ink }}>{writer.name}</div>
<div style={{ fontSize: 11, color: P.muted, marginTop: 2 }}>@{writer.username}</div>
</button>
))}
</div>
</div>
)}
{showArticles ? (
<>
{isOwn && ['writer', 'editor', 'admin'].includes(profile.role) && (
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: `2px solid ${P.line}` }}>
{[{ id: 'articles', label: 'مقالات' }, { id: 'analytics', label: 'آمار و تحلیل' }].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={{
background: 'none', border: 'none', padding: '10px 20px', cursor: 'pointer',
fontFamily: 'Vazirmatn,sans-serif', fontSize: 14, fontWeight: activeTab === tab.id ? 800 : 500,
color: activeTab === tab.id ? P.terracotta : P.muted,
borderBottom: activeTab === tab.id ? `2px solid ${P.terracotta}` : '2px solid transparent',
marginBottom: -2, transition: 'all 0.15s',
}}>{tab.label}</button>
))}
</div>
)}
{activeTab === 'analytics' && isOwn && ['writer', 'editor', 'admin'].includes(profile.role) ? (
<WriterAnalyticsTab />
) : (
<>
<h2 style={{ fontSize: 18, fontWeight: 800, color: P.ink, margin: '0 0 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ display: 'inline-block', width: 4, height: 20, background: P.terracotta, borderRadius: 2 }} />
مقالات
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{articles.map(article => (
<ArticleMiniCard
key={article.id}
article={article}
onClick={() => navigate('/article/' + article.slug)}
/>
))}
</div>
</>
)}
</>
) : (
<div style={{
textAlign: 'center', padding: '64px 24px', color: P.muted,
background: P.panel, borderRadius: 16, border: `1px solid ${P.line}`,
}}>
<div style={{ fontSize: 48, marginBottom: 16 }}>✦</div>
<div style={{ fontSize: 15, fontWeight: 600, color: P.ink, marginBottom: 8 }}>هنوز مقاله‌ای منتشر نشده</div>
<div style={{ fontSize: 13 }}>مقالات این کاربر اینجا نمایش داده خواهند شد.</div>
</div>
)}
</div>
{showSettings && (
<ProfileSettingsModal
user={profile}
account={me}
onClose={() => setShowSettings(false)}
onSaved={updated => setProfile(p => ({ ...p, ...updated }))}
/>
)}
<style>{`
@media (max-width: 600px) {
.profile-hero-inner { flex-direction: column !important; align-items: flex-start !important; gap: 20px !important; }
.profile-own-grid { grid-template-columns: 1fr !important; }
}
`}</style>
</div>
);
}