diff --git a/app/teknav-ui.jsx b/app/teknav-ui.jsx
new file mode 100644
index 0000000..39d2970
--- /dev/null
+++ b/app/teknav-ui.jsx
@@ -0,0 +1,938 @@
+// teknav-ui.jsx — Shared UI components (ES module)
+import { useState, useEffect, useRef, useCallback, createContext, useContext } from 'react';
+import Lenis from 'lenis';
+import { api, ApiError } from './src/lib/api.js';
+import { contentApi } from './src/lib/content-api.js';
+import { engagementApi } from './src/lib/engagement-api.js';
+import TeknavCAP from './src/lib/TeknavCAP.jsx';
+
+// ── Toast System ────────────────────────────────────────────────────────────
+const ToastCtx = createContext(null);
+function ToastProvider({ children }) {
+ const [toasts, setToasts] = useState([]);
+ const add = useCallback((msg, type = 'success') => {
+ const id = Date.now();
+ setToasts(t => [...t, { id, msg, type }]);
+ setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3200);
+ }, []);
+ return (
+
+ {children}
+
+ {toasts.map(t => (
+
{t.msg}
+ ))}
+
+
+ );
+}
+const useToast = () => useContext(ToastCtx);
+
+// ── Navigation Context ──────────────────────────────────────────────────────
+const NavCtx = createContext(null);
+function getCurrentPage() {
+ const hashPath = window.location.hash.startsWith('#/') ? window.location.hash.slice(1) : '';
+ if (hashPath) {
+ window.history.replaceState(null, '', hashPath);
+ return hashPath;
+ }
+ const pathname = window.location.pathname;
+ // Strip trailing slash (except root "/") so /article/slug/ resolves same as /article/slug
+ const cleanPath = pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
+ const path = `${cleanPath}${window.location.search}`;
+ return path === '' ? '/' : path;
+}
+function NavProvider({ children }) {
+ const [page, setPage] = useState(getCurrentPage);
+ const lenisRef = useRef(null);
+
+ // Boot Lenis once
+ useEffect(() => {
+ const lenis = new Lenis({
+ duration: 1.2,
+ easing: t => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
+ smoothWheel: true,
+ wheelMultiplier: 1,
+ touchMultiplier: 1.5,
+ infinite: false,
+ });
+ lenisRef.current = lenis;
+
+ let raf;
+ function loop(time) {
+ lenis.raf(time);
+ raf = requestAnimationFrame(loop);
+ }
+ raf = requestAnimationFrame(loop);
+
+ return () => {
+ cancelAnimationFrame(raf);
+ lenis.destroy();
+ lenisRef.current = null;
+ };
+ }, []);
+
+ const navigate = useCallback((to) => {
+ if (`${window.location.pathname}${window.location.search}` !== to) {
+ window.history.pushState(null, '', to);
+ }
+ setPage(to);
+ // Instant jump to top on route change (no smooth scroll between pages)
+ if (lenisRef.current) {
+ lenisRef.current.scrollTo(0, { immediate: true });
+ } else {
+ window.scrollTo(0, 0);
+ }
+ }, []);
+
+ useEffect(() => {
+ const handler = () => setPage(getCurrentPage());
+ window.addEventListener('popstate', handler);
+ window.addEventListener('hashchange', handler);
+ return () => {
+ window.removeEventListener('popstate', handler);
+ window.removeEventListener('hashchange', handler);
+ };
+ }, []);
+
+ return {children};
+}
+const useNav = () => useContext(NavCtx);
+
+// ── Auth Context ────────────────────────────────────────────────────────────
+// Backend-backed auth: cookie session (HttpOnly tek_sid) + double-submit CSRF
+// (tek_csrf, mirrored to X-CSRF-Token by src/lib/api.js). The server is the
+// only source of truth — there's no `user` in localStorage anymore.
+const AuthCtx = createContext(null);
+function AuthProvider({ children }) {
+ const [user, setUser] = useState(null);
+ const [bootstrapping, setBootstrapping] = useState(true);
+
+ // On mount, ask the server who we are (cookie may already be valid from a
+ // previous tab). 401 just means "not signed in" — not an error.
+ const refreshMe = async () => {
+ const res = await api.get('/api/auth/me');
+ setUser(res?.user ?? null);
+ return res?.user ?? null;
+ };
+
+ useEffect(() => {
+ let cancelled = false;
+ api.get('/api/auth/me')
+ .then((res) => { if (!cancelled) setUser(res?.user ?? null); })
+ .catch((e) => { if (!(e instanceof ApiError && e.status === 401)) console.error('[auth/me]', e); })
+ .finally(() => { if (!cancelled) setBootstrapping(false); });
+ return () => { cancelled = true; };
+ }, []);
+
+ const login = async (identifier, password, captchaId, userSolution) => {
+ const res = await api.post('/api/auth/login', { identifier, password, captchaId, userSolution });
+ if (res?.user) setUser(res.user);
+ return res?.twoFactorRequired ? res : (res?.user ?? null);
+ };
+
+ const verifyTwoFactor = async (ticket, code) => {
+ const res = await api.post('/api/auth/2fa/verify', { ticket, code });
+ if (res?.user) setUser(res.user);
+ return res?.user ?? null;
+ };
+
+ const signup = async ({ name, username, email, phone, password, captchaId, userSolution }) => {
+ const res = await api.post('/api/auth/signup', { name, username, email, phone, password, captchaId, userSolution });
+ if (res?.user) setUser(res.user);
+ return res?.user ?? null;
+ };
+
+ const startOAuth = (provider) => {
+ window.location.href = `/api/auth/oauth/${provider}/start`;
+ };
+
+ const logout = async () => {
+ try { await api.post('/api/auth/logout', {}); } catch (e) { console.warn('[auth/logout]', e); }
+ setUser(null);
+ };
+
+ return {children};
+}
+const useAuth = () => useContext(AuthCtx);
+
+// ── Badges ──────────────────────────────────────────────────────────────────
+const TYPE_COLORS = {
+ 'تحلیل عمیق': { bg: '#0F6B73', text: '#fff' },
+ 'راهنمای فنی': { bg: '#D49A2A', text: '#fff' },
+ 'دادهنما': { bg: '#C76D4A', text: '#fff' },
+ 'پژوهش': { bg: '#2F8F6B', text: '#fff' },
+ 'راستیآزماییشده': { bg: '#263238', text: '#fff' },
+ 'خبر فوری': { bg: '#C94C4C', text: '#fff' },
+ 'خبر تحلیلی': { bg: '#5F6B6D', text: '#fff' },
+ 'پرونده': { bg: '#20343A', text: '#FAF7F0' },
+ 'default': { bg: '#E4DDD2', text: '#263238' },
+};
+function CategoryBadge({ name, color, small }) {
+ return (
+ {name}
+ );
+}
+function TypeBadge({ type, small }) {
+ const c = TYPE_COLORS[type] || TYPE_COLORS.default;
+ return (
+ {type}
+ );
+}
+const STATUS_COLORS = {
+ 'منتشرشده': '#2F8F6B', 'پیشنویس': '#5F6B6D', 'در انتظار بررسی': '#D49A2A',
+ 'نیازمند اصلاح': '#C94C4C', 'زمانبندیشده': '#0F6B73',
+};
+function StatusBadge({ status }) {
+ const color = STATUS_COLORS[status] || '#5F6B6D';
+ return (
+
+
+ {status}
+
+ );
+}
+const ROLE_COLORS = { admin: '#C76D4A', editor: '#0F6B73', writer: '#D49A2A', reviewer: '#2F8F6B', reader: '#5F6B6D' };
+const ROLE_NAMES = { admin: 'مدیر کل', editor: 'سردبیر', writer: 'نویسنده', reviewer: 'بازبین', reader: 'خواننده' };
+function RoleBadge({ role }) {
+ const color = ROLE_COLORS[role] || '#5F6B6D';
+ return (
+ {ROLE_NAMES[role] || role}
+ );
+}
+
+// ── Header ──────────────────────────────────────────────────────────────────
+const NAV_ITEMS = [
+ { label: 'خانه', path: '/' },
+ { label: 'هوش مصنوعی', path: '/category/ai' },
+ { label: 'علم داده', path: '/category/data' },
+ { label: 'استارتاپ', path: '/category/startup' },
+ { label: 'امنیت', path: '/category/security' },
+ { label: 'سختافزار', path: '/category/hardware' },
+ { label: 'نرمافزار', path: '/category/software' },
+ { label: 'آینده فناوری', path: '/category/future' },
+ { label: 'نویسندگان', path: '/authors' },
+];
+
+function Header() {
+ const { navigate, page } = useNav();
+ const { user, logout } = useAuth();
+ const [scrolled, setScrolled] = useState(false);
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const [searchOpen, setSearchOpen] = useState(false);
+ const [searchQ, setSearchQ] = useState('');
+ const searchRef = useRef();
+
+ useEffect(() => {
+ const h = () => setScrolled(window.scrollY > 20);
+ window.addEventListener('scroll', h);
+ return () => window.removeEventListener('scroll', h);
+ }, []);
+
+ useEffect(() => {
+ if (searchOpen) setTimeout(() => searchRef.current?.focus(), 100);
+ }, [searchOpen]);
+
+ const handleSearch = (e) => {
+ e.preventDefault();
+ if (searchQ.trim()) { navigate('/search?q=' + encodeURIComponent(searchQ)); setSearchOpen(false); setSearchQ(''); }
+ };
+
+ return (
+ <>
+
+
+ {/* Logo */}
+
+
+ {/* Nav */}
+
+
+ {/* Actions */}
+
+
+ {user ? (
+
+
+ {user.role !== 'reader' && (
+
+ )}
+ {user.username && (
+
+ )}
+ {user.role === 'reader' && !user.username && (
+ {user.name}
+ )}
+
+
+ ) : (
+
+ )}
+
+
+
+
+ {/* Search bar */}
+ {searchOpen && (
+
+
+
+ )}
+
+
+ {/* Mobile Drawer */}
+ {mobileOpen && (
+
+
setMobileOpen(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)' }} />
+
+
+ تکناو
+
+
+ {NAV_ITEMS.map(item => (
+
+ ))}
+ {user ? (
+
+ {user.username && (
+
+ )}
+ {user.role !== 'reader' && (
+
+ )}
+
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ >
+ );
+}
+
+function NotificationBell() {
+ const { navigate } = useNav();
+ const [open, setOpen] = useState(false);
+ const [items, setItems] = useState([]);
+ const [unread, setUnread] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ engagementApi.listNotifications({ limit: 20 })
+ .then(res => {
+ if (!cancelled) {
+ setItems(res?.items ?? []);
+ setUnread(res?.unread ?? 0);
+ }
+ })
+ .catch(() => {});
+ const es = new EventSource('/api/v1/auth/notifications/stream', { withCredentials: true });
+ es.addEventListener('notification', (event) => {
+ const notification = JSON.parse(event.data);
+ setItems(list => [notification, ...list].slice(0, 20));
+ setUnread(n => n + 1);
+ });
+ es.onerror = () => {};
+ return () => { cancelled = true; es.close(); };
+ }, []);
+
+ const openNotification = async (notification) => {
+ if (!notification.read) {
+ engagementApi.markNotificationRead(notification.id).catch(() => {});
+ setUnread(n => Math.max(0, n - 1));
+ setItems(list => list.map(item => item.id === notification.id ? { ...item, read: true } : item));
+ }
+ const slug = notification.payload?.articleSlug;
+ if (slug) navigate('/article/' + slug);
+ setOpen(false);
+ };
+
+ const markAll = async () => {
+ await engagementApi.markNotificationsRead().catch(() => {});
+ setUnread(0);
+ setItems(list => list.map(item => ({ ...item, read: true })));
+ };
+
+ return (
+
+
+ {open && (
+
+
+ اعلانها
+
+
+ {items.length === 0 ? (
+
اعلانی وجود ندارد.
+ ) : items.map(item => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function notificationText(item) {
+ if (item.type === 'comment_reply') return `${item.payload?.actorName ?? 'کاربر'} به نظر شما پاسخ داد`;
+ if (item.type === 'comment') return `${item.payload?.actorName ?? 'کاربر'} برای مقاله شما نظر گذاشت`;
+ if (item.type === 'new_article') return `مقاله تازه از ${item.payload?.actorName ?? 'نویسنده'}`;
+ return 'اعلان جدید';
+}
+
+// ── Footer ──────────────────────────────────────────────────────────────────
+function Footer() {
+ const { navigate } = useNav();
+ return (
+
+ );
+}
+
+// ── Newsletter Form ─────────────────────────────────────────────────────────
+function NewsletterForm() {
+ const [email, setEmail] = useState('');
+ const [state, setState] = useState('idle');
+ const [captcha, setCaptcha] = useState({ captchaId: '', userSolution: '' });
+ const toast = useToast();
+
+ const submit = async (e) => {
+ e.preventDefault();
+ if (!email.includes('@') || !email.includes('.')) { toast('لطفاً یک ایمیل معتبر وارد کنید', 'error'); return; }
+ if (!captcha.captchaId || !captcha.userSolution.trim()) { toast('لطفاً کد امنیتی را وارد کنید', 'error'); return; }
+ try {
+ const res = await contentApi.subscribeNewsletter(email, captcha.captchaId, captcha.userSolution);
+ setState('done');
+ toast(res?.alreadySubscribed ? 'این ایمیل قبلاً ثبت شده است' : 'با موفقیت عضو خبرنامه شدید!', res?.alreadySubscribed ? 'warning' : 'success');
+ } catch (err) {
+ if (err instanceof ApiError && err.status === 429) toast('تعداد درخواستها از حد مجاز گذشت؛ کمی بعد دوباره تلاش کنید', 'warning');
+ else if (err instanceof ApiError && err.status === 400) toast('کد امنیتی نامعتبر است', 'error');
+ else toast('خطا در ثبت عضویت خبرنامه', 'error');
+ }
+ };
+
+ if (state === 'done') return (
+
+
✓
+
عضویت شما ثبت شد!
+
جدیدترین مقالههای تکناو را در ایمیل دریافت خواهید کرد.
+
+ );
+
+ return (
+
+ );
+}
+
+// ── Skeleton Loader ─────────────────────────────────────────────────────────
+function SkeletonLoader({ lines = 3, height = 16 }) {
+ return (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+
+ );
+}
+
+// ── Confirm Modal ───────────────────────────────────────────────────────────
+function ConfirmModal({ msg, onConfirm, onCancel }) {
+ useEffect(() => {
+ const h = (e) => { if (e.key === 'Escape') onCancel(); };
+ window.addEventListener('keydown', h);
+ return () => window.removeEventListener('keydown', h);
+ }, [onCancel]);
+ return (
+
+
+
+
تأیید عملیات
+
{msg}
+
+
+
+
+
+
+ );
+}
+
+// ── Empty State ─────────────────────────────────────────────────────────────
+function EmptyState({ title = 'نتیجهای یافت نشد', subtitle = 'با فیلترهای دیگر امتحان کنید' }) {
+ return (
+
+
◎
+
{title}
+
{subtitle}
+
+ );
+}
+
+// ── Reading Progress ────────────────────────────────────────────────────────
+function ReadingProgress() {
+ const [pct, setPct] = useState(0);
+ useEffect(() => {
+ const h = () => {
+ const el = document.documentElement;
+ const scroll = el.scrollTop || document.body.scrollTop;
+ const total = el.scrollHeight - el.clientHeight;
+ setPct(total > 0 ? (scroll / total) * 100 : 0);
+ };
+ window.addEventListener('scroll', h);
+ return () => window.removeEventListener('scroll', h);
+ }, []);
+ return (
+
+ );
+}
+
+// ── Author Avatar ───────────────────────────────────────────────────────────
+function AuthorAvatar({ author, size = 40 }) {
+ return (
+
{author.initials || author.name.slice(0, 2)}
+ );
+}
+
+// ── Shared styles ───────────────────────────────────────────────────────────
+const iconBtnStyle = {
+ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: 36, height: 36, borderRadius: 8, transition: 'background 0.15s',
+};
+const pillBtn = {
+ border: 'none', cursor: 'pointer', padding: '8px 18px', borderRadius: 8,
+ fontSize: 13, fontFamily: 'Vazirmatn,sans-serif', fontWeight: 600,
+ transition: 'all 0.15s', display: 'inline-flex', alignItems: 'center', gap: 6,
+};
+const inputStyle = {
+ border: '1px solid #E4DDD2', borderRadius: 8, padding: '9px 14px',
+ fontSize: 13, fontFamily: 'Vazirmatn,sans-serif', background: '#fff',
+ color: '#263238', outline: 'none', width: '100%',
+};
+
+// ── Streak Widget ───────────────────────────────────────────────────────────
+function StreakWidget() {
+ const { user } = useAuth();
+ const [data, setData] = useState(null);
+ useEffect(() => {
+ if (user) engagementApi.getStreaks().then(setData).catch(() => {});
+ }, [user]);
+ if (!user || !data || data.streak === 0) return null;
+ return (
+
+
🔥
+
+
{data.streak.toLocaleString('fa-IR')} روز مطالعه متوالی!
+
به یادگیری ادامه دهید
+
+
+ );
+}
+
+// ── Continue Reading Bar ───────────────────────────────────────────────────
+function ContinueReadingBar() {
+ const { user } = useAuth();
+ const { navigate } = useNav();
+ const [data, setData] = useState(null);
+ useEffect(() => {
+ if (user) engagementApi.getStreaks().then(setData).catch(() => {});
+ }, [user]);
+ if (!user || !data || !data.continueReading?.length) return null;
+ return (
+
+
+
ادامه مطالعه:
+ {data.continueReading.map(a => (
+
+ ))}
+
+
+ );
+}
+
+// ── Reading List Modal ──────────────────────────────────────────────────────
+function ReadingListModal({ articleId, onDone }) {
+ const [lists, setLists] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [newListName, setNewListName] = useState('');
+ const toast = useToast();
+
+ const load = useCallback(() => {
+ setLoading(true);
+ engagementApi.listReadingLists()
+ .then(res => setLists(res.items ?? []))
+ .finally(() => setLoading(false));
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const add = async (listId) => {
+ try {
+ await engagementApi.addToReadingList(listId, articleId);
+ toast('به لیست اضافه شد');
+ onDone();
+ } catch (e) {
+ toast('در این لیست موجود است', 'warning');
+ }
+ };
+
+ const create = async (e) => {
+ e.preventDefault();
+ if (!newListName.trim()) return;
+ try {
+ const res = await engagementApi.createReadingList(newListName);
+ setNewListName('');
+ add(res.list.id);
+ } catch {
+ toast('خطا در ایجاد لیست', 'error');
+ }
+ };
+
+ return (
+
+
+
+
+
ذخیره در لیست مطالعه
+
+
+
+ {loading ?
: (
+
+ {lists.map(list => (
+
+ ))}
+
+ )}
+
+
+
+
+ );
+}
+
+// ── Push Notification Prompt ────────────────────────────────────────────────
+import { pushApi } from './src/lib/engagement-api.js';
+
+function PushNotificationPrompt({ user }) {
+ const [visible, setVisible] = useState(false);
+ const toast = useToast();
+
+ useEffect(() => {
+ if (
+ user &&
+ 'PushManager' in window &&
+ 'serviceWorker' in navigator &&
+ typeof Notification !== 'undefined' &&
+ Notification.permission === 'default' &&
+ !localStorage.getItem('push_prompt_dismissed')
+ ) {
+ setVisible(true);
+ }
+ }, [user]);
+
+ if (!visible) return null;
+
+ const handleEnable = async () => {
+ try {
+ const perm = await Notification.requestPermission();
+ if (perm === 'granted') {
+ await pushApi.subscribe();
+ toast('اعلانهای مرورگر فعال شد');
+ }
+ } catch {
+ // ignore — user may have denied or push not configured
+ }
+ setVisible(false);
+ };
+
+ const dismiss = () => {
+ localStorage.setItem('push_prompt_dismissed', '1');
+ setVisible(false);
+ };
+
+ return (
+
+
🔔
+
+
اعلانهای مرورگر
+
مقالات جدید را از دست ندهید
+
+
+
+
+
+
+ );
+}
+
+// ── Install Prompt ──────────────────────────────────────────────────────────
+function InstallPrompt() {
+ const [deferredPrompt, setDeferredPrompt] = useState(null);
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const handler = (e) => {
+ e.preventDefault();
+ setDeferredPrompt(e);
+ setVisible(true);
+ };
+ window.addEventListener('beforeinstallprompt', handler);
+ return () => window.removeEventListener('beforeinstallprompt', handler);
+ }, []);
+
+ const install = async () => {
+ if (!deferredPrompt) return;
+ deferredPrompt.prompt();
+ const { outcome } = await deferredPrompt.userChoice;
+ if (outcome === 'accepted') setVisible(false);
+ setDeferredPrompt(null);
+ };
+
+ if (!visible) return null;
+
+ return (
+
+
+
نصب اپلیکیشن تکناو
+
دسترسی سریعتر و آفلاین به مقالات
+
+
+
+
+
+
+ );
+}
+
+function useViewportPrefetch(ref, slug) {
+ useEffect(() => {
+ if (!ref.current || !slug) return;
+
+ const conn = navigator.connection;
+ if (conn && (conn.saveData || /2g|3g/.test(conn.effectiveType))) {
+ return;
+ }
+
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const prefetch = () => {
+ contentApi.getArticle(slug).catch(() => {});
+ };
+ if ('requestIdleCallback' in window) {
+ window.requestIdleCallback(prefetch);
+ } else {
+ setTimeout(prefetch, 200);
+ }
+ observer.disconnect();
+ }
+ });
+ }, { rootMargin: '100px' });
+
+ observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, [ref, slug]);
+}
+
+export {
+ ToastProvider, useToast, NavProvider, useNav, AuthProvider, useAuth,
+ CategoryBadge, TypeBadge, StatusBadge, RoleBadge, AuthorAvatar,
+ Header, Footer, NewsletterForm, SkeletonLoader, ConfirmModal, EmptyState, ReadingProgress,
+ StreakWidget, ContinueReadingBar, ReadingListModal, InstallPrompt, PushNotificationPrompt,
+ iconBtnStyle, pillBtn, inputStyle, useViewportPrefetch,
+};
+