// 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 */}
navigate('/')} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', flexDirection: 'column', lineHeight: 1 }} aria-label="خانه تکناو">
تکناو
TEKNAV
{/* Nav */}
{NAV_ITEMS.map(item => (
navigate(item.path)} style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 12px',
fontSize: 13, fontFamily: 'Vazirmatn,sans-serif', whiteSpace: 'nowrap',
color: page === item.path ? '#0F6B73' : '#263238',
fontWeight: page === item.path ? 700 : 400,
borderBottom: page === item.path ? '2px solid #0F6B73' : '2px solid transparent',
transition: 'all 0.15s',
}} aria-current={page === item.path ? 'page' : undefined}>{item.label}
))}
{/* Actions */}
setSearchOpen(s => !s)} style={iconBtnStyle} aria-label="جستجو در تکناو" aria-expanded={searchOpen}>
{user ? (
{user.role !== 'reader' && (
navigate('/admin')} style={{ ...pillBtn, background: '#0F6B73', color: '#fff' }} aria-label="پنل مدیریت">{ROLE_NAMES[user.role] || user.role}
)}
{user.username && (
navigate('/profile/@' + user.username)} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238' }} aria-label={`پروفایل @${user.username}`}>@{user.username}
)}
{user.role === 'reader' && !user.username && (
{user.name}
)}
{ logout(); navigate('/'); }} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238' }} aria-label="خروج از حساب">خروج
) : (
navigate('/login')} style={{ ...pillBtn, background: '#C46A4D', color: '#fff' }} aria-label="ورود یا ثبتنام در تکناو">ورود / ثبتنام
)}
setMobileOpen(o => !o)} style={{ ...iconBtnStyle, display: 'none' }} className="mobile-menu-btn" aria-label="باز کردن منوی موبایل" aria-expanded={mobileOpen}>
{/* Search bar */}
{searchOpen && (
)}
{/* Mobile Drawer */}
{mobileOpen && (
setMobileOpen(false)} style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.4)' }} />
تکناو
setMobileOpen(false)} style={iconBtnStyle}>✕
{NAV_ITEMS.map(item => (
{ navigate(item.path); setMobileOpen(false); }} style={{
display: 'block', width: '100%', textAlign: 'right', background: 'none', border: 'none',
padding: '12px 0', fontSize: 15, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer',
color: page === item.path ? '#0F6B73' : '#263238', fontWeight: page === item.path ? 700 : 400,
borderBottom: '1px solid #E4DDD2',
}}>{item.label}
))}
{user ? (
{user.username && (
{ navigate('/profile/@' + user.username); setMobileOpen(false); }} style={{ ...pillBtn, width: '100%', justifyContent: 'center' }}>@{user.username}
)}
{user.role !== 'reader' && (
{ navigate('/admin'); setMobileOpen(false); }} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', width: '100%', justifyContent: 'center' }}>پنل مدیریت
)}
{ logout(); navigate('/'); setMobileOpen(false); }} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', width: '100%', justifyContent: 'center' }}>خروج
) : (
{ navigate('/login'); setMobileOpen(false); }} style={{ ...pillBtn, background: '#C46A4D', color: '#fff', width: '100%', justifyContent: 'center', marginTop: 20 }}>ورود / ثبتنام
)}
)}
>
);
}
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 (
setOpen(o => !o)} style={{ ...iconBtnStyle, position: 'relative' }} aria-label="اعلانها">
{unread > 0 && (
{unread > 9 ? '9+' : unread.toLocaleString('fa-IR')}
)}
{open && (
اعلانها
خواندن همه
{items.length === 0 ? (
اعلانی وجود ندارد.
) : items.map(item => (
openNotification(item)} style={{ display: 'block', width: '100%', border: 'none', borderBottom: '1px solid #F4EFE6', background: item.read ? '#fff' : '#F4EFE6', textAlign: 'right', padding: 12, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
{notificationText(item)}
{item.payload?.articleTitle ?? ''}
))}
)}
);
}
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 (
{/* Decorative top accent */}
تکناو
TEKNAV
روایت عمیق فناوری. تحلیلهای علمی و تخصصی از دنیای فناوری برای متخصصان ایرانی.
دستهبندیها
{['هوش مصنوعی', 'علم داده', 'امنیت سایبری', 'سختافزار', 'نرمافزار'].map(c => (
{ e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateX(-4px)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#5F6B6D'; e.currentTarget.style.transform = 'translateX(0)'; }}>{c}
))}
ابزارها
{[
['مقایسههای فنی', '/compare'],
['استوریها', '/stories'],
['دایرکتوری شرکتها', '/directory'],
['واژگان تخصصی', '/glossary/ai'],
['ابزارهای تکناو', '/tools'],
].map(([label, path]) => (
navigate(path)} style={{ fontSize: 13, marginBottom: 9, cursor: 'pointer', color: '#5F6B6D', transition: 'color 0.15s, transform 0.15s', transformOrigin: 'right' }}
onMouseEnter={e => { e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateX(-4px)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#5F6B6D'; e.currentTarget.style.transform = 'translateX(0)'; }}>{label}
))}
تکناو
{[
['درباره ما', '/about'],
['تیم نویسندگان', '/authors'],
['قوانین حریم خصوصی', '/privacy'],
['شرایط استفاده', '/terms'],
['تماس با ما', '/contact'],
].map(([label, path]) => (
navigate(path)} style={{ fontSize: 13, marginBottom: 9, cursor: 'pointer', color: '#5F6B6D', transition: 'color 0.15s, transform 0.15s', transformOrigin: 'right' }}
onMouseEnter={e => { e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateX(-4px)'; }}
onMouseLeave={e => { e.currentTarget.style.color = '#5F6B6D'; e.currentTarget.style.transform = 'translateX(0)'; }}>{label}
))}
ارتباط با ما
{['توییتر', 'لینکدین', 'تلگرام'].map(s => (
{ e.currentTarget.style.background = '#0F6B73'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.transform = 'translateY(-2px)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(15,107,115,0.08)'; e.currentTarget.style.color = '#0F6B73'; e.currentTarget.style.transform = 'translateY(0)'; }}>{s}
))}
info@teknav.ir
🌊 حامی خلیج فارس
🌐 حامی اینترنت آزاد برای عموم
© ۱۴۰۵ تکناو — تمام حقوق محفوظ است
Designed By Coaxys
);
}
// ── 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 (
);
}
// ── 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 => (
navigate('/article/' + a.slug)} style={{
background: 'none', border: 'none', color: '#fff', cursor: 'pointer',
display: 'flex', flexDirection: 'column', gap: 2, textAlign: 'right', minWidth: 140,
}}>
{a.title}
))}
);
}
// ── 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 => (
add(list.id)} style={{
width: '100%', padding: '12px 16px', borderRadius: 10, background: '#fff', border: '1px solid #E4DDD2',
textAlign: 'right', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
fontFamily: 'Vazirmatn,sans-serif', transition: 'all 0.15s',
}} onMouseEnter={e => { e.currentTarget.style.borderColor = '#0F6B73'; e.currentTarget.style.background = '#0F6B7308'; }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#E4DDD2'; e.currentTarget.style.background = '#fff'; }}>
{list.name}
{list._count?.items ?? 0} مورد
))}
)}
);
}
// ── 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 (
نصب اپلیکیشن تکناو
دسترسی سریعتر و آفلاین به مقالات
setVisible(false)} style={{ ...pillBtn, background: '#F4EFE6', color: '#263238', padding: '6px 12px', fontSize: 12 }}>بعداً
نصب
);
}
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,
};