// 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 (
{r.label}
);
}
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 (
{preview
?

: (
{initials}
)
}
{editable && (
<>
>
)}
);
}
// ── 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 (
);
}
// ── Article mini-card ────────────────────────────────────────────────────────
function ArticleMiniCard({ article, onClick }) {
const date = article.publishedAt ? new Date(article.publishedAt).toLocaleDateString('fa-IR') : '';
return (
{ 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 = ''; }}
>
{article.title}
{article.summary &&
{article.summary.slice(0, 140)}{article.summary.length > 140 ? '…' : ''}
}
{date && {date}}
{article.views ?? 0}
♥ {article.reactions ?? 0}
);
}
// ── 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 (
ویرایش پروفایل
set('name', v)} placeholder="نام کامل شما" maxLength={80} />
{canEditUsername ? (
set('username', v.toLowerCase())} placeholder="coaxys" maxLength={30} />
) : (
نام کاربری برای کاربران عادی قابل تغییر نیست.
)}
set('bio', v)} multiline placeholder="چند جمله درباره خودتان…" maxLength={280} />
{'PushManager' in window && (
اعلانهای مرورگر
{pushBlocked ? (
مرورگر شما اجازه اعلان را مسدود کرده است. برای فعالسازی، از تنظیمات مرورگر اجازه دهید.
) : (
اعلانهای مقالات جدید و نظرات: {pushEnabled ? 'فعال' : 'غیرفعال'}
)}
)}
{error && {error}
}
);
}
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 (
{open && (
{prefs === null ? (
در حال بارگذاری…
) : (
| رویداد |
{CHANNELS.map((ch) => (
{CHANNEL_LABELS[ch]} |
))}
{Object.entries(EVENT_LABELS).map(([evt, label]) => (
| {label} |
{CHANNELS.map((ch) => (
|
))}
))}
)}
)}
);
}
// ── Inline SVG sparkline ─────────────────────────────────────────────────────
function Sparkline({ data, color = '#C45C3D', width = 120, height = 36 }) {
if (!data || data.length < 2) return ;
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 (
);
}
// ── 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 در حال بارگذاری…
;
const totalViews = overview?.views ?? 0;
const totalReactions = overview?.reactions ?? 0;
const totalComments = overview?.comments ?? 0;
const viewsByDay = overview?.viewsByDay ?? [];
return (
{/* Summary cards */}
{[
{ 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 }) => (
))}
{/* 30-day views sparkline */}
{viewsByDay.length > 1 && (
بازدید ۳۰ روز اخیر
{viewsByDay[0]?.date?.slice(5) ?? ''}
{viewsByDay[viewsByDay.length - 1]?.date?.slice(5) ?? ''}
)}
{/* Per-article table */}
{articles.length > 0 && (
عملکرد مقالهها
{['عنوان', 'بازدید', 'واکنش', 'نظر', 'ذخیره'].map(h => (
| {h} |
))}
{articles.map((a, i) => (
| {a.title} |
{(a.views ?? 0).toLocaleString('fa-IR')} |
{(a.reactions ?? 0).toLocaleString('fa-IR')} |
{(a.comments ?? 0).toLocaleString('fa-IR')} |
{(a.saved ?? 0).toLocaleString('fa-IR')} |
))}
)}
{articles.length === 0 && (
هنوز دادهای برای نمایش وجود ندارد.
)}
);
}
// ── 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 (
در حال بارگذاری…
);
}
if (!profile) {
return ;
}
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 (
{/* Hero banner */}
{/* Decorative shapes */}
setProfile(p => ({ ...p, avatarUrl: url }))}
/>
{profile.name}
{profile.author?.verifiedExpert && متخصص تاییدشده}
@{profile.username}
{profile.author?.verifiedExpert && profile.author?.verificationNote && (
{profile.author.verificationNote}
)}
{profile.bio &&
{profile.bio}
}
{(profile.alumniOf || profile.credential) && (
{profile.alumniOf && (
دانشآموخته: {profile.alumniOf.name}
{profile.alumniOf.sameAs && (
تاییدیه نهاد
)}
)}
{profile.credential && (
درجه علمی: {profile.credential.name}
)}
)}
{profile.expertise?.length > 0 && (
{profile.expertise.map(tag => (
{tag}
))}
)}
{joinDate &&
عضو از {joinDate}}
{showArticles &&
{articleCount} مقاله}
{['writer', 'editor', 'admin'].includes(profile.role) && !profile._static &&
{followers.count.toLocaleString('fa-IR')} دنبالکننده}
{profile.social?.twitter && profile.social.twitter !== '#' && (
𝕏
)}
{profile.social?.linkedin && profile.social.linkedin !== '#' && (
in
)}
{canFollow && (
)}
{isOwn && (
)}
{/* Stats bar (for writers/editors) */}
{showArticles && (
{[
{ 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 }) => (
))}
)}
{/* Earned badges */}
{badges.length > 0 && (
نشانهای کسبشده
{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 (
{iconNode(m.icon)}
{m.label}
);
})}
)}
{/* Article list */}
{isOwn && (
تاریخچه خواندن
{history.length === 0 ?
هنوز مقالهای در تاریخچه شما ثبت نشده است.
: history.slice(0, 5).map(item => (
))}
{history.length > 0 &&
}
نویسندگان دنبالشده
{following.length === 0 ?
هنوز نویسندهای را دنبال نمیکنید.
: following.slice(0, 6).map(writer => (
))}
)}
{showArticles ? (
<>
{isOwn && ['writer', 'editor', 'admin'].includes(profile.role) && (
{[{ id: 'articles', label: 'مقالات' }, { id: 'analytics', label: 'آمار و تحلیل' }].map(tab => (
))}
)}
{activeTab === 'analytics' && isOwn && ['writer', 'editor', 'admin'].includes(profile.role) ? (
) : (
<>
مقالات
{articles.map(article => (
navigate('/article/' + article.slug)}
/>
))}
>
)}
>
) : (
✦
هنوز مقالهای منتشر نشده
مقالات این کاربر اینجا نمایش داده خواهند شد.
)}
{showSettings && (
setShowSettings(false)}
onSaved={updated => setProfile(p => ({ ...p, ...updated }))}
/>
)}
);
}