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

2028 lines
118 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.
// teknav-pages.jsx — Search, Category, Authors pages (ES module)
import { useState, useMemo, useEffect, useCallback } from 'react';
import { TeknavData } from './teknav-data.js';
function authorProfilePath(author) {
const username = author.username || TeknavData.authors.find(a => a.slug === author.slug)?.username;
return username ? `/profile/@${username}` : `/author/${author.slug}`;
}
import {
useNav, useToast, useAuth,
AuthorAvatar, CategoryBadge, TypeBadge, EmptyState, SkeletonLoader,
inputStyle, pillBtn,
} from './teknav-ui.jsx';
import { DiagramRenderer } from './teknav-diagrams.jsx';
import { ArticleRow } from './teknav-articles.jsx';
import { contentApi } from './src/lib/content-api.js';
import { engagementApi, pushApi } from './src/lib/engagement-api.js';
// ── Search Page ─────────────────────────────────────────────────────────────
function SearchPage({ initQ = '' }) {
const [q, setQ] = useState(initQ);
const [input, setInput] = useState(initQ);
const [filters, setFilters] = useState({ category: '', author: '', type: '', tag: '', dateRange: '' });
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const { navigate } = useNav();
const load = useCallback(() => {
setLoading(true);
const params = {
q,
category: filters.category,
author: filters.author,
type: filters.type,
tag: filters.tag,
limit: 50,
};
const apiCall = q ? contentApi.search(params) : contentApi.listArticles(params);
apiCall
.then(res => setItems(res.items ?? []))
.finally(() => setLoading(false));
}, [q, filters]);
useEffect(() => { load(); }, [load]);
const doSearch = (e) => { e.preventDefault(); setQ(input); };
const highlightText = (text, query) => {
if (!query) return text;
const parts = text.split(new RegExp(`(${query})`, 'gi'));
return parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase()
? `<mark style="background:#D49A2A30;color:#263238;border-radius:2px;padding:0 2px">${part}</mark>`
: part
).join('');
};
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: 28, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>جستجوی پیشرفته</h1>
<form onSubmit={doSearch} style={{ display: 'flex', gap: 10, marginBottom: 32 }}>
<input value={input} onChange={e => setInput(e.target.value)} placeholder="جستجو در مقاله‌ها..."
style={{ flex: 1, ...inputStyle, fontSize: 16 }} dir="rtl" autoFocus />
<button type="submit" style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '12px 32px' }}>جستجو</button>
</form>
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 32 }} className="search-layout">
<aside style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 12, color: '#263238' }}>دسته‌بندی</div>
<select value={filters.category} onChange={e => setFilters(f => ({ ...f, category: e.target.value }))} style={inputStyle}>
<option value="">همه دسته‌ها</option>
{TeknavData.categories.map(c => <option key={c.id} value={c.slug}>{c.name}</option>)}
</select>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 12, color: '#263238' }}>نویسنده</div>
<select value={filters.author} onChange={e => setFilters(f => ({ ...f, author: e.target.value }))} style={inputStyle}>
<option value="">همه نویسندگان</option>
{TeknavData.authors.map(a => <option key={a.id} value={a.slug}>{a.name}</option>)}
</select>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, marginBottom: 12, color: '#263238' }}>نوع محتوا</div>
<select value={filters.type} onChange={e => setFilters(f => ({ ...f, type: e.target.value }))} style={inputStyle}>
<option value="">همه انواع</option>
{['تحلیل عمیق', 'راهنمای فنی', 'داده‌نما', 'خبر فوری'].map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<button onClick={() => setFilters({ category: '', author: '', type: '', tag: '', dateRange: '' })} style={{ ...pillBtn, background: '#F4EFE6', color: '#C94C4C', justifyContent: 'center' }}>پاک کردن فیلترها</button>
</aside>
<section>
{loading ? <SkeletonLoader lines={5} /> : (
<>
{items.length === 0 ? <EmptyState title="نتیجه‌ای یافت نشد" /> : (
<div>
<div style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 20 }}>{items.length.toLocaleString('fa-IR')} نتیجه پیدا شد</div>
{items.map((a, i) => <SearchResult key={a.id} article={a} q={q} highlight={highlightText} idx={i} />)}
</div>
)}
</>
)}
</section>
</div>
</div>
<style>{`.search-layout { grid-template-columns: 240px 1fr; } @media(max-width:768px){ .search-layout { grid-template-columns: 1fr !important; } }`}</style>
</div>
);
}
function SearchResult({ article, q, highlight, idx }) {
const { navigate } = useNav();
return (
<div onClick={() => navigate('/article/' + article.slug)} style={{
padding: '24px 0', borderBottom: '1px solid #E4DDD2', cursor: 'pointer',
opacity: 0, animation: `fadeIn 0.3s ${idx * 40}ms forwards`,
}}>
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<CategoryBadge name={article.categoryName} color={article.categoryColor} small />
<span style={{ fontSize: 11, color: '#5F6B6D' }}>{article.date} · {article.readTime} دقیقه</span>
</div>
<h3 style={{ fontSize: 17, fontWeight: 800, color: '#263238', margin: '0 0 8px' }}
dangerouslySetInnerHTML={{ __html: highlight(article.title, q) }} />
<p style={{ fontSize: 14, color: '#5F6B6D', lineHeight: 1.7, margin: 0 }}
dangerouslySetInnerHTML={{ __html: highlight(article.summary.slice(0, 180) + '…', q) }} />
</div>
);
}
// ── Category Page ───────────────────────────────────────────────────────────
function CategoryPage({ slug }) {
const { navigate } = useNav();
const [cat, setCat] = useState(() => TeknavData.categories.find(c => c.slug === slug));
const [articles, setArticles] = useState(() => contentApi.fallbackArticles().filter(a => a.category === slug));
useEffect(() => {
let cancelled = false;
setCat(TeknavData.categories.find(c => c.slug === slug));
setArticles(contentApi.fallbackArticles().filter(a => a.category === slug));
Promise.all([
contentApi.getCategory(slug).catch(() => null),
contentApi.listArticles({ category: slug, limit: 100 }).catch(() => null),
]).then(([category, result]) => {
if (cancelled) return;
if (category) setCat({ ...(TeknavData.categories.find(c => c.slug === slug) ?? {}), ...category, id: category.slug });
if (result?.items) setArticles(result.items);
});
return () => { cancelled = true; };
}, [slug]);
if (!cat) return <EmptyState title="دسته‌بندی یافت نشد" />;
return (
<div style={{ paddingTop: 64, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif' }}>
{/* Category Hero */}
{(() => {
const HERO_IMAGES = {
ai: '/images/category-hero-ai-v2.jpg',
startup: '/images/category-hero-startup.jpg',
hardware: '/images/category-hero-hardware.jpg',
security: '/images/category-hero-security.jpg',
software: '/images/category-hero-software.jpg',
future: '/images/category-hero-future.jpg',
data: '/images/category-hero-data.jpg',
};
const heroImg = HERO_IMAGES[cat.slug];
return (
<div style={{ position: 'relative', borderBottom: '1px solid #E4DDD2', direction: 'rtl', overflow: 'hidden', minHeight: 340 }}>
{/* Background: photo for AI, soft tinted gradient for others */}
{heroImg
? <div style={{ position: 'absolute', inset: 0, backgroundImage: `url(${heroImg})`, backgroundSize: 'cover', backgroundPosition: 'center' }} aria-hidden="true" />
: <div style={{ position: 'absolute', inset: 0, background: `linear-gradient(135deg, ${cat.color}12 0%, #F4EFE6 100%)` }} aria-hidden="true" />
}
{/* Content grid */}
<div style={{ position: 'relative', zIndex: 1, maxWidth: 1280, margin: '0 auto', display: 'grid', gridTemplateColumns: '1fr 420px', gap: 32, alignItems: 'stretch', padding: '64px 32px' }} className="cat-hero-grid">
{/* Text card */}
<div style={{
background: '#FFFFFF',
border: '1px solid #E4DDD2',
borderRight: `5px solid ${cat.color}`,
borderRadius: '0 20px 20px 0',
padding: '44px 48px 40px',
boxShadow: '0 6px 40px rgba(15,42,46,0.10)',
}}>
{/* Pill badge */}
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: cat.color + '12', border: `1px solid ${cat.color}30`, color: cat.color, padding: '5px 16px', borderRadius: 999, fontSize: 12, fontWeight: 700, marginBottom: 22, letterSpacing: '0.06em' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: cat.color, display: 'inline-block' }} />
دسته‌بندی
</div>
<h1 style={{ fontSize: 'clamp(32px,5vw,52px)', fontWeight: 900, color: '#263238', margin: '0 0 14px', fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1.2 }}>{cat.name}</h1>
<p style={{ fontSize: 16, color: '#5F6B6D', lineHeight: 2.0, margin: '0 0 30px', maxWidth: 560 }}>{cat.description}</p>
{/* Stats */}
<div style={{ display: 'flex', gap: 0, borderTop: `1px solid #E4DDD2`, paddingTop: 24 }}>
{[['تعداد مقاله', cat.articleCount], ['نویسنده فعال', cat.authorCount], ['میانگین زمان مطالعه', `${cat.avgReadTime} دقیقه`]].map(([label, val], i) => (
<div key={label} style={{ flex: '1 1 100px', textAlign: 'center', padding: '0 20px', borderRight: i > 0 ? '1px solid #E4DDD2' : 'none' }}>
<div style={{ fontSize: 'clamp(22px,3vw,30px)', fontWeight: 900, color: cat.color, fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1 }}>{val}</div>
<div style={{ fontSize: 12, color: '#9AA5A6', marginTop: 6 }}>{label}</div>
</div>
))}
</div>
</div>
{/* Diagram card */}
<div style={{
background: '#FFFFFF',
border: '1px solid #E4DDD2',
borderRadius: 20,
padding: '28px',
boxShadow: '0 6px 32px rgba(15,42,46,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} className="cat-hero-diagram">
<DiagramRenderer type={cat.diagram} />
</div>
</div>
</div>
);
})()}
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '40px 24px', direction: 'rtl' }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#263238', margin: '0 0 24px' }}>مقاله‌های {cat.name} ({articles.length})</h2>
{articles.length === 0 ? <EmptyState title="مقاله‌ای در این دسته وجود ندارد" /> : (
articles.map((a, i) => <ArticleRow key={a.id} article={a} idx={i} />)
)}
</div>
<style>{`
.cat-hero-grid { grid-template-columns: 1fr 420px; }
@media(max-width: 1024px) {
.cat-hero-grid { grid-template-columns: 1fr 340px !important; padding: 48px 24px !important; }
}
@media(max-width: 860px) {
.cat-hero-grid { grid-template-columns: 1fr !important; gap: 20px !important; padding: 36px 20px !important; }
.cat-hero-diagram { display: none !important; }
}
@media(max-width: 560px) {
.cat-hero-grid { padding: 28px 14px !important; }
.cat-hero-grid > div:first-child { padding: 28px 22px !important; border-radius: 14px !important; }
}
`}</style>
</div>
);
}
// ── Authors List Page ────────────────────────────────────────────────────────
function TagPage({ tag }) {
const decoded = decodeURIComponent(tag);
const [articles, setArticles] = useState(() => contentApi.fallbackArticles().filter((item) => item.tags?.includes(decoded)));
useEffect(() => {
let cancelled = false;
setArticles(contentApi.fallbackArticles().filter((item) => item.tags?.includes(decoded)));
contentApi.listArticles({ tag: decoded, limit: 100 })
.then((res) => { if (!cancelled) setArticles(res.items ?? []); })
.catch(() => {});
return () => { cancelled = true; };
}, [decoded]);
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: 30, fontWeight: 900, color: '#263238', margin: '0 0 10px' }}>برچسب {decoded}</h1>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>همه مقاله‌هایی که با این برچسب منتشر شده‌اند.</p>
{articles.length === 0 ? <EmptyState title="مقاله‌ای برای این برچسب پیدا نشد" /> : articles.map((article, index) => (
<ArticleRow key={article.id} article={article} idx={index} />
))}
</div>
</div>
);
}
function GlossaryPage({ slug }) {
const { navigate } = useNav();
const term = TeknavData.glossary?.find((item) => item.slug === slug);
const articles = contentApi.fallbackArticles().filter((article) => (
article.tags?.some((tag) => tag.toLowerCase() === term?.term?.toLowerCase() || tag.toLowerCase() === term?.english?.toLowerCase())
|| article.summary?.toLowerCase().includes(term?.english?.toLowerCase() || '')
|| article.title?.toLowerCase().includes(term?.english?.toLowerCase() || '')
)).slice(0, 6);
if (!term) return <EmptyState title="واژه پیدا نشد" />;
const related = (term.related ?? []).map((item) => TeknavData.glossary?.find((g) => g.slug === item)).filter(Boolean);
const topic = TeknavData.topicHubs?.find((item) => item.slug === term.topic);
return (
<main style={{ paddingTop: 76, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<section style={{ borderBottom: '1px solid #E4DDD2', background: 'linear-gradient(135deg,#FFFFFF 0%,#F4EFE6 100%)', padding: '44px 24px' }}>
<div style={{ maxWidth: 1040, margin: '0 auto' }}>
<button onClick={() => navigate('/articles')} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>بازگشت به مقاله‌ها</button>
<div style={{ color: '#0F6B73', fontSize: 12, fontWeight: 900, marginBottom: 8 }}>فرهنگ واژگان تکنّاو</div>
<h1 style={{ fontSize: 'clamp(30px,5vw,48px)', color: '#263238', margin: '0 0 6px', fontWeight: 900 }}>{term.term}</h1>
<div style={{ color: '#5F6B6D', fontSize: 18, direction: 'ltr', textAlign: 'right', marginBottom: 18 }}>{term.english}</div>
<p style={{ maxWidth: 720, color: '#263238', lineHeight: 2, fontSize: 17, margin: 0 }}>{term.summary}</p>
</div>
</section>
<section style={{ maxWidth: 1040, margin: '0 auto', padding: '32px 24px', display: 'grid', gridTemplateColumns: '1.1fr 0.9fr', gap: 24 }} className="glossary-grid">
<div style={{ display: 'grid', gap: 18 }}>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 20 }}>
<h2 style={{ margin: '0 0 14px', color: '#263238', fontSize: 20 }}>پرسش‌های رایج</h2>
{(term.faqs ?? []).map(([q, a]) => (
<div key={q} style={{ borderTop: '1px solid #E4DDD2', padding: '14px 0' }}>
<strong style={{ color: '#0F6B73' }}>{q}</strong>
<p style={{ margin: '8px 0 0', color: '#5F6B6D', lineHeight: 1.9 }}>{a}</p>
</div>
))}
</div>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 20 }}>
<h2 style={{ margin: '0 0 14px', color: '#263238', fontSize: 20 }}>مقاله‌های مرتبط</h2>
{articles.length === 0 ? <EmptyState title="هنوز مقاله مرتبطی پیدا نشد" /> : articles.map((article, index) => <ArticleRow key={article.id} article={article} idx={index} />)}
</div>
</div>
<aside style={{ display: 'grid', gap: 14, alignContent: 'start' }}>
{topic && (
<button onClick={() => navigate(`/topics/${topic.slug}`)} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 18, textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
<div style={{ fontSize: 12, color: '#5F6B6D' }}>خوشه موضوعی</div>
<div style={{ fontSize: 18, color: '#0F6B73', fontWeight: 900 }}>{topic.title}</div>
</button>
)}
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: 18 }}>
<h3 style={{ margin: '0 0 12px', color: '#263238', fontSize: 16 }}>واژه‌های مرتبط</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{related.map((item) => (
<button key={item.slug} onClick={() => navigate(`/glossary/${item.slug}`)} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', fontSize: 12 }}>{item.term}</button>
))}
</div>
</div>
</aside>
</section>
<style>{`@media(max-width:850px){.glossary-grid{grid-template-columns:1fr!important}}`}</style>
</main>
);
}
function toDateKey(article) {
return article?.dateEn || article?.publishedAt || article?.date || '';
}
function AuthorsListPage() {
const { navigate } = useNav();
const [authors, setAuthors] = useState(() => contentApi.fallbackAuthors());
const [articles, setArticles] = useState(() => contentApi.fallbackArticles());
useEffect(() => {
let cancelled = false;
Promise.all([
contentApi.listAuthors().catch(() => null),
contentApi.listArticles({ limit: 100 }).catch(() => null),
]).then(([authorRows, articleRows]) => {
if (cancelled) return;
if (authorRows) setAuthors(authorRows);
if (articleRows?.items) setArticles(articleRows.items);
});
return () => { cancelled = true; };
}, []);
return (
<div style={{ paddingTop: 64, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif' }}>
{/* Authors Hero */}
<div style={{ position: 'relative', borderBottom: '1px solid #E4DDD2', direction: 'rtl', overflow: 'hidden', minHeight: 220 }}>
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'url(/images/authors-hero.jpg)', backgroundSize: 'cover', backgroundPosition: 'center' }} aria-hidden="true" />
<div style={{ position: 'relative', zIndex: 1, maxWidth: 1100, margin: '0 auto', padding: '52px 32px' }}>
<div style={{
display: 'inline-block',
background: '#FFFFFF',
border: '1px solid #E4DDD2',
borderRight: '5px solid #C46A4D',
borderRadius: '0 20px 20px 0',
padding: '32px 44px 28px',
boxShadow: '0 6px 40px rgba(15,42,46,0.10)',
maxWidth: 560,
}}>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: '#C46A4D12', border: '1px solid #C46A4D30', color: '#C46A4D', padding: '5px 16px', borderRadius: 999, fontSize: 12, fontWeight: 700, marginBottom: 18, letterSpacing: '0.06em' }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#C46A4D', display: 'inline-block' }} />
نویسندگان
</div>
<h1 style={{ fontSize: 'clamp(26px,4vw,40px)', fontWeight: 900, color: '#263238', margin: '0 0 10px', fontFamily: "'PelakFA','Vazirmatn',sans-serif", lineHeight: 1.2 }}>نویسندگان تکناو</h1>
<p style={{ fontSize: 15, color: '#5F6B6D', margin: 0, lineHeight: 1.9 }}>متخصصانی که روایت عمیق فناوری را می‌نویسند</p>
</div>
</div>
</div>
<div style={{ maxWidth: 1100, margin: '0 auto', padding: '0 24px', direction: 'rtl' }}>
<div style={{ display: 'none' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(300px,1fr))', gap: 20, padding: '32px 0' }}>
{authors.map(author => {
const latestArt = articles.find(a => a.authorId === author.id || a.authorSlug === author.slug);
return (
<div key={author.id} onClick={() => navigate(authorProfilePath(author))} style={{
background: '#fff', border: '1px solid #E4DDD2', borderRadius: 14, padding: 24,
cursor: 'pointer', direction: 'rtl', transition: 'all 0.2s',
}} onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-3px)'; e.currentTarget.style.boxShadow = '0 10px 32px rgba(0,0,0,0.08)'; }}
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = 'none'; }}>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<AuthorAvatar author={author} size={60} />
<div>
<div style={{ fontSize: 16, fontWeight: 700, color: '#263238', marginBottom: 3, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
{author.name}
{author.verifiedExpert && <span style={{ fontSize: 10, color: '#0F6B73', background: '#0F6B7314', border: '1px solid #0F6B7330', borderRadius: 999, padding: '2px 7px' }}>تاییدشده</span>}
</div>
<div style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 6 }}>{author.specialty}</div>
<div style={{ fontSize: 12, color: '#0F6B73', fontWeight: 600 }}>{author.liveArticleCount ?? author.articleCount} مقاله منتشرشده</div>
</div>
</div>
<p style={{ fontSize: 13, color: '#5F6B6D', lineHeight: 1.7, margin: '0 0 16px' }}>{author.bio.slice(0, 120)}…</p>
{latestArt && (
<div style={{ background: '#F4EFE6', borderRadius: 8, padding: '10px 14px', fontSize: 12, color: '#263238' }}>
<span style={{ color: '#5F6B6D' }}>آخرین مقاله: </span>
{latestArt.title.slice(0, 50)}…
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
// ── Author Profile Page ─────────────────────────────────────────────────────
function SeriesPage({ slug }) {
const { navigate } = useNav();
const [series, setSeries] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
setError(false);
contentApi.getSeries(slug)
.then((item) => { if (!cancelled) setSeries(item); })
.catch(() => { if (!cancelled) setError(true); });
return () => { cancelled = true; };
}, [slug]);
if (error) {
return (
<div style={{ paddingTop: 100, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<EmptyState title="سری مقاله پیدا نشد" subtitle="این مجموعه در API وجود ندارد یا هنوز منتشر نشده است." />
</div>
);
}
if (!series) {
return <div style={{ paddingTop: 120, minHeight: '60vh', textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', color: '#5F6B6D' }}>در حال بارگذاری...</div>;
}
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 960, margin: '0 auto', padding: '40px 24px' }}>
<button onClick={() => navigate('/articles')} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>بازگشت به مقاله‌ها</button>
<h1 style={{ fontSize: 30, fontWeight: 900, color: '#263238', margin: '0 0 10px' }}>{series.title}</h1>
{series.description && <p style={{ color: '#5F6B6D', fontSize: 15, lineHeight: 1.9, margin: '0 0 28px' }}>{series.description}</p>}
{(series.articles ?? []).length === 0 ? <EmptyState title="هنوز مقاله‌ای در این سری نیست" /> : (
<div style={{ display: 'grid', gap: 14 }}>
{series.articles.map((item, index) => (
<div key={item.article.id} style={{ display: 'grid', gridTemplateColumns: '44px 1fr', gap: 14, alignItems: 'start' }}>
<div style={{ width: 36, height: 36, borderRadius: 999, background: '#0F6B73', color: '#fff', display: 'grid', placeItems: 'center', fontWeight: 900 }}>{(item.position ?? index + 1).toLocaleString('fa-IR')}</div>
<ArticleRow article={item.article} idx={index} />
</div>
))}
</div>
)}
</div>
</div>
);
}
function TopicHubPage({ slug }) {
const { navigate } = useNav();
const toast = useToast();
const { user } = useAuth();
const topic = TeknavData.topicHubs.find((item) => item.slug === slug);
const category = TeknavData.categories.find((item) => item.slug === topic?.categorySlug);
const [articles, setArticles] = useState(() => contentApi.fallbackArticles().filter((item) => item.category === topic?.categorySlug));
const [series, setSeries] = useState([]);
const [follow, setFollow] = useState({ following: false, count: 0 });
const [guestPushEnabled, setGuestPushEnabled] = useState(false);
const [guestPushBusy, setGuestPushBusy] = useState(false);
useEffect(() => {
if (!topic) return;
let cancelled = false;
setArticles(contentApi.fallbackArticles().filter((item) => item.category === topic.categorySlug));
Promise.all([
contentApi.listArticles({ category: topic.categorySlug, limit: 100 }).catch(() => null),
contentApi.listSeries().catch(() => []),
engagementApi.topicFollowers(topic.slug).catch(() => null),
]).then(([articleRows, seriesRows, followRow]) => {
if (cancelled) return;
if (articleRows?.items) setArticles(articleRows.items);
setSeries((seriesRows ?? []).filter((row) => (row.articles ?? []).some((entry) => entry.article?.category === topic.categorySlug)));
if (followRow) setFollow({ following: !!followRow.following, count: followRow.count ?? 0 });
});
// Check if guest push already subscribed
if ('PushManager' in window && 'serviceWorker' in navigator) {
pushApi.isSubscribed().then(setGuestPushEnabled).catch(() => {});
}
return () => { cancelled = true; };
}, [topic?.slug]);
if (!topic) {
return (
<div style={{ paddingTop: 100, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<EmptyState title="موضوع پیدا نشد" subtitle="این خوشه محتوایی هنوز در تکناو تعریف نشده است." />
</div>
);
}
const featured = articles.filter((item) => item.featured).slice(0, 3);
const latest = articles.slice().sort((a, b) => String(toDateKey(b)).localeCompare(String(toDateKey(a)))).slice(0, 8);
const authors = TeknavData.authors.filter((author) => articles.some((article) => article.authorId === author.id || article.authorSlug === author.slug));
const toggleTopicFollow = async () => {
if (!user) {
toast('برای دنبال کردن موضوع ابتدا وارد شوید', 'warning');
return;
}
try {
const result = follow.following
? await engagementApi.unfollowTopic(topic.slug)
: await engagementApi.followTopic(topic.slug);
setFollow({ following: !!result.following, count: result.count ?? 0 });
toast(result.following ? 'موضوع دنبال شد' : 'دنبال کردن موضوع لغو شد');
} catch {
toast('دنبال کردن موضوع انجام نشد', 'warning');
}
};
const toggleGuestPush = async () => {
if (!('PushManager' in window) || !('serviceWorker' in navigator)) {
toast('مرورگر شما از اعلان‌های وب پشتیبانی نمی‌کند', 'warning');
return;
}
setGuestPushBusy(true);
try {
if (guestPushEnabled) {
await pushApi.unsubscribeGuest();
setGuestPushEnabled(false);
toast('اعلان‌های این موضوع غیرفعال شد');
} else {
const perm = typeof Notification !== 'undefined' ? await Notification.requestPermission() : 'denied';
if (perm !== 'granted') {
toast('اجازه اعلان داده نشد', 'warning');
return;
}
await pushApi.subscribeAsGuest([topic.slug]);
setGuestPushEnabled(true);
toast('اعلان‌های مقالات جدید این موضوع فعال شد');
}
} catch {
toast('عملیات ناموفق بود', 'error');
} finally {
setGuestPushBusy(false);
}
};
return (
<div style={{ paddingTop: 72, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<section style={{ borderBottom: '1px solid #E4DDD2', background: `linear-gradient(135deg, ${category?.color ?? '#0F6B73'}18, #F4EFE6)`, padding: '44px 24px' }}>
<div style={{ maxWidth: 1120, margin: '0 auto' }}>
<button onClick={() => navigate('/articles')} style={{ ...pillBtn, background: '#FFFFFFAA', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>همه مقاله‌ها</button>
<h1 style={{ fontSize: 'clamp(30px,5vw,48px)', color: '#263238', fontWeight: 900, margin: '0 0 12px' }}>{topic.title}</h1>
<p style={{ maxWidth: 760, color: '#4F5D60', fontSize: 16, lineHeight: 1.95, margin: 0 }}>{topic.intro}</p>
<div style={{ display: 'flex', gap: 10, marginTop: 18, flexWrap: 'wrap' }}>
<button onClick={toggleTopicFollow} style={{ ...pillBtn, background: follow.following ? '#0F6B73' : '#fff', color: follow.following ? '#fff' : '#0F6B73', border: '1px solid #0F6B7330' }}>
{follow.following ? 'دنبال می‌کنید' : 'دنبال کردن موضوع'} · {(follow.count ?? 0).toLocaleString('fa-IR')}
</button>
{'PushManager' in window && (
<button onClick={toggleGuestPush} disabled={guestPushBusy} style={{ ...pillBtn, background: guestPushEnabled ? '#fff' : '#FAF7F0', color: '#0F6B73', border: '1px solid #0F6B7330', opacity: guestPushBusy ? 0.6 : 1 }}>
{guestPushBusy ? '…' : guestPushEnabled ? 'اعلان فعال است' : 'دریافت اعلان مقالات'}
</button>
)}
</div>
</div>
</section>
<div style={{ maxWidth: 1120, margin: '0 auto', padding: '36px 24px', display: 'grid', gap: 34 }}>
<section>
<h2 style={{ color: '#263238', fontSize: 22, margin: '0 0 18px' }}>مقاله‌های شاخص</h2>
{featured.length === 0 ? <EmptyState title="مقاله شاخصی برای این موضوع ثبت نشده است" /> : (
<div style={{ display: 'grid', gap: 14 }}>
{featured.map((article, index) => <ArticleRow key={article.id} article={article} idx={index} />)}
</div>
)}
</section>
<section>
<h2 style={{ color: '#263238', fontSize: 22, margin: '0 0 18px' }}>تازه‌ترین تحلیل‌ها</h2>
{latest.length === 0 ? <EmptyState title="هنوز مقاله‌ای در این موضوع منتشر نشده است" /> : latest.map((article, index) => <ArticleRow key={article.id} article={article} idx={index} />)}
</section>
<section style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(260px,1fr))', gap: 18 }}>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
<h2 style={{ color: '#263238', fontSize: 18, margin: '0 0 14px' }}>سری‌های مرتبط</h2>
{series.length === 0 ? <p style={{ color: '#5F6B6D', lineHeight: 1.8, margin: 0 }}>سری مقاله مرتبط پس از تایید سردبیر اینجا نمایش داده می‌شود.</p> : series.map((item) => (
<button key={item.id} onClick={() => navigate(`/series/${item.slug}`)} style={{ display: 'block', width: '100%', textAlign: 'right', background: '#F4EFE6', border: '1px solid #E4DDD2', borderRadius: 8, padding: 12, marginBottom: 10, color: '#263238', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>{item.title}</button>
))}
</div>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
<h2 style={{ color: '#263238', fontSize: 18, margin: '0 0 14px' }}>نویسندگان مرتبط</h2>
{authors.length === 0 ? <p style={{ color: '#5F6B6D', lineHeight: 1.8, margin: 0 }}>نویسنده مرتبطی پیدا نشد.</p> : authors.map((author) => (
<button key={author.id} onClick={() => navigate(authorProfilePath(author))} style={{ display: 'flex', alignItems: 'center', gap: 10, width: '100%', background: 'transparent', border: 0, padding: '8px 0', color: '#263238', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', textAlign: 'right' }}>
<AuthorAvatar author={author} size={36} />
<span>{author.name}</span>
</button>
))}
</div>
</section>
<section style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
<h2 style={{ color: '#263238', fontSize: 18, margin: '0 0 14px' }}>پرسش‌های رایج</h2>
{topic.faqs.map(([question, answer]) => (
<details key={question} style={{ borderTop: '1px solid #E4DDD2', padding: '12px 0' }}>
<summary style={{ cursor: 'pointer', fontWeight: 700, color: '#263238' }}>{question}</summary>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '10px 0 0' }}>{answer}</p>
</details>
))}
</section>
</div>
</div>
);
}
function AuthorProfilePage({ slug }) {
const { navigate } = useNav();
const [author, setAuthor] = useState(null);
const [articles, setArticles] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Redirect /author/:slug → /profile/@:username
const staticAuthor = TeknavData.authors.find(a => a.slug === slug);
const username = staticAuthor?.username || slug;
navigate(`/profile/@${username}`);
}, [slug]);
if (loading) return <div style={{ paddingTop: 120, textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', color: '#5F6B6D' }}>در حال بارگذاری…</div>;
if (!author) return <EmptyState title="نویسنده یافت نشد" />;
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
{/* Profile header */}
<div style={{ background: 'linear-gradient(135deg,#20343A,#0F2830)', padding: '48px 24px' }}>
<div style={{ maxWidth: 900, margin: '0 auto', display: 'flex', gap: 28, alignItems: 'center', flexWrap: 'wrap' }}>
<AuthorAvatar author={author} size={96} />
<div>
<h1 style={{ fontSize: 28, fontWeight: 900, color: '#FAF7F0', margin: '0 0 6px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
{author.name}
{author.verifiedExpert && <span style={{ fontSize: 12, color: '#D49A2A', background: 'rgba(212,154,42,0.12)', border: '1px solid rgba(212,154,42,0.45)', borderRadius: 999, padding: '3px 10px' }}>متخصص تاییدشده</span>}
</h1>
{author.verifiedExpert && author.verificationNote && <div style={{ color: '#CFE2DD', fontSize: 12, marginBottom: 8 }}>{author.verificationNote}</div>}
<div style={{ fontSize: 14, color: '#D49A2A', fontWeight: 600, marginBottom: 10 }}>{author.specialty}</div>
<p style={{ fontSize: 14, color: '#90A4AE', lineHeight: 1.8, margin: '0 0 16px', maxWidth: 520 }}>{author.bio}</p>
<div style={{ display: 'flex', gap: 24 }}>
<div><span style={{ fontSize: 22, fontWeight: 800, color: '#D49A2A' }}>{author.articleCount}</span><span style={{ fontSize: 12, color: '#90A4AE', marginRight: 4 }}>مقاله</span></div>
<div><span style={{ fontSize: 22, fontWeight: 800, color: '#D49A2A' }}>{articles.reduce((s, a) => s + (a.views || 0), 0).toLocaleString('fa')}</span><span style={{ fontSize: 12, color: '#90A4AE', marginRight: 4 }}>بازدید</span></div>
</div>
</div>
</div>
</div>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '32px 24px' }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#263238', margin: '0 0 24px' }}>مقاله‌های {author.name}</h2>
{articles.length === 0 ? <EmptyState title="مقاله‌ای منتشر نشده" /> :
articles.map((a, i) => <ArticleRow key={a.id} article={a} idx={i} />)
}
</div>
</div>
);
}
function NewsletterArchivePage() {
const { navigate } = useNav();
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
contentApi.listNewsletterArchive()
.then((rows) => { if (!cancelled) setItems(rows); })
.catch(() => { if (!cancelled) setItems([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 10px' }}>آرشیو خبرنامه تکنـاو</h1>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>نسخه‌های ارسال‌شده خبرنامه برای دنبال کردن روندهای مهم فناوری، هوش مصنوعی و امنیت.</p>
{loading ? <div style={{ color: '#5F6B6D' }}>در حال بارگذاری...</div> : items.length === 0 ? <EmptyState title="هنوز خبرنامه‌ای منتشر نشده است" /> : (
<div style={{ display: 'grid', gap: 14 }}>
{items.map((item) => (
<button key={item.id} onClick={() => navigate(`/newsletter/${item.slug}`)} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18, textAlign: 'right', cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
<div style={{ fontSize: 18, fontWeight: 800, color: '#263238', marginBottom: 6 }}>{item.subject}</div>
<div style={{ color: '#5F6B6D', fontSize: 12 }}>{item.sentAt ? new Date(item.sentAt).toLocaleDateString('fa-IR') : 'منتشر نشده'}</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
function NewsletterIssuePage({ slug }) {
const { navigate } = useNav();
const [issue, setIssue] = useState(null);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
setError(false);
contentApi.getNewsletterIssue(slug)
.then((row) => { if (!cancelled) setIssue(row); })
.catch(() => { if (!cancelled) setError(true); });
return () => { cancelled = true; };
}, [slug]);
if (error) return <div style={{ paddingTop: 100, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}><EmptyState title="خبرنامه پیدا نشد" /></div>;
if (!issue) return <div style={{ paddingTop: 120, textAlign: 'center', color: '#5F6B6D', fontFamily: 'Vazirmatn,sans-serif' }}>در حال بارگذاری...</div>;
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<article style={{ maxWidth: 760, margin: '0 auto', padding: '40px 24px' }}>
<button onClick={() => navigate('/newsletter')} style={{ ...pillBtn, background: '#F4EFE6', color: '#0F6B73', border: '1px solid #E4DDD2', marginBottom: 18 }}>آرشیو خبرنامه</button>
<h1 style={{ fontSize: 30, fontWeight: 900, color: '#263238', lineHeight: 1.45, margin: '0 0 10px' }}>{issue.subject}</h1>
<div style={{ color: '#5F6B6D', fontSize: 12, marginBottom: 28 }}>{issue.sentAt ? new Date(issue.sentAt).toLocaleDateString('fa-IR') : ''}</div>
<div className="article-content" style={{ fontSize: 16, lineHeight: 2, color: '#263238', background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24 }} dangerouslySetInnerHTML={{ __html: issue.bodyHtml }} />
</article>
</div>
);
}
function JobsPage() {
const toast = useToast();
const [items, setItems] = useState([]);
const [form, setForm] = useState({ company: '', title: '', location: 'Remote', remote: true, url: '', contactEmail: '', description: '' });
const [busy, setBusy] = useState(false);
useEffect(() => {
contentApi.listJobs().then(setItems).catch(() => setItems([]));
}, []);
const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const submit = async (event) => {
event.preventDefault();
setBusy(true);
try {
await contentApi.submitJob(form);
setForm({ company: '', title: '', location: 'Remote', remote: true, url: '', contactEmail: '', description: '' });
toast('آگهی شغلی برای بررسی ارسال شد');
} catch {
toast('ارسال آگهی ناموفق بود', 'error');
} finally {
setBusy(false);
}
};
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 1040, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>فرصت‌های شغلی فناوری</h1>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>جایگاه‌های منتخب شرکت‌های فنی فارسی‌زبان. آگهی‌های جدید بعد از بررسی تحریریه منتشر می‌شوند.</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 360px', gap: 22 }} className="jobs-grid">
<section style={{ display: 'grid', gap: 12 }}>
{items.length === 0 ? <EmptyState title="هنوز آگهی منتشرشده‌ای وجود ندارد" /> : items.map((job) => (
<article key={job.id} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<h2 style={{ margin: '0 0 6px', fontSize: 18, color: '#263238' }}>{job.title}</h2>
<div style={{ color: '#0F6B73', fontWeight: 800, fontSize: 13 }}>{job.company}</div>
</div>
<span style={{ color: '#5F6B6D', fontSize: 12 }}>{job.remote ? 'Remote' : job.location}</span>
</div>
<p style={{ color: '#5F6B6D', lineHeight: 1.8, fontSize: 13 }}>{job.description}</p>
{job.url && <a href={job.url} target="_blank" rel="noreferrer" style={{ color: '#0F6B73', fontWeight: 800 }}>مشاهده و ارسال رزومه</a>}
</article>
))}
</section>
<form onSubmit={submit} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 18, alignSelf: 'start', display: 'grid', gap: 10 }}>
<h2 style={{ margin: 0, fontSize: 17, color: '#263238' }}>ثبت آگهی شرکت</h2>
<input required value={form.company} onChange={e => set('company', e.target.value)} placeholder="نام شرکت" style={inputStyle} />
<input required value={form.title} onChange={e => set('title', e.target.value)} placeholder="عنوان شغل" style={inputStyle} />
<input value={form.location} onChange={e => set('location', e.target.value)} placeholder="موقعیت" style={inputStyle} />
<input value={form.contactEmail} onChange={e => set('contactEmail', e.target.value)} placeholder="ایمیل تماس" style={inputStyle} dir="ltr" />
<input value={form.url} onChange={e => set('url', e.target.value)} placeholder="لینک درخواست" style={inputStyle} dir="ltr" />
<label style={{ display: 'flex', gap: 8, fontSize: 13, color: '#263238' }}><input type="checkbox" checked={form.remote} onChange={e => set('remote', e.target.checked)} /> امکان دورکاری</label>
<textarea required value={form.description} onChange={e => set('description', e.target.value)} placeholder="شرح موقعیت" style={{ ...inputStyle, minHeight: 110, resize: 'vertical' }} />
<button disabled={busy} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', justifyContent: 'center' }}>{busy ? 'در حال ارسال...' : 'ارسال برای بررسی'}</button>
</form>
</div>
</div>
<style>{`@media(max-width:850px){.jobs-grid{grid-template-columns:1fr!important}}`}</style>
</div>
);
}
function CoursesPage() {
const [items, setItems] = useState([]);
useEffect(() => { contentApi.listCourses().then(setItems).catch(() => setItems([])); }, []);
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 1040, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>دوره‌های تخصصی تکناو</h1>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, margin: '0 0 28px' }}>مسیرهای آموزشی فارسی برای تیم‌هایی که فناوری را در محصول و عملیات واقعی به کار می‌گیرند.</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(260px,1fr))', gap: 16 }}>
{items.map(course => (
<article key={course.id} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
<div style={{ color: '#0F6B73', fontSize: 12, fontWeight: 800, marginBottom: 8 }}>{course.level}</div>
<h2 style={{ margin: '0 0 10px', color: '#263238', fontSize: 19 }}>{course.title}</h2>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, fontSize: 13 }}>{course.summary}</p>
<div style={{ marginTop: 16, color: '#D49A2A', fontWeight: 900 }}>{course.price ? `${course.price.toLocaleString('fa-IR')} تومان` : 'به‌زودی'}</div>
</article>
))}
</div>
</div>
</div>
);
}
function MembershipPage() {
const { navigate } = useNav();
const { user } = useAuth();
const toast = useToast();
const [loading, setLoading] = useState(false);
const [status, setStatus] = useState(null);
useEffect(() => {
fetch('/api/v1/membership/status', { credentials: 'include' })
.then((r) => r.ok ? r.json() : null)
.then((d) => d && setStatus(d))
.catch(() => {});
}, []);
const subscribe = async () => {
if (!user) { navigate('/login'); return; }
setLoading(true);
try {
const res = await fetch('/api/v1/membership/subscribe', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
} else {
toast(data.error === 'payment_unavailable' ? 'درگاه پرداخت در دسترس نیست' : 'خطا در اتصال به درگاه پرداخت');
setLoading(false);
}
} catch {
toast('خطا در ارتباط با سرور');
setLoading(false);
}
};
const isPremium = status?.active;
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 900, margin: '0 auto', padding: '44px 24px' }}>
<h1 style={{ fontSize: 32, fontWeight: 900, color: '#263238', margin: '0 0 8px' }}>عضویت پریمیوم تکناو</h1>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, marginBottom: 28 }}>
تجربه بدون تبلیغ، مقاله‌های اختصاصی، دوره‌های کوتاه و گزارش‌های عمیق برای خوانندگان حرفه‌ای.
</p>
{isPremium && (
<div style={{ background: '#0F6B7318', border: '1px solid #0F6B7340', borderRadius: 12, padding: '16px 20px', marginBottom: 28, color: '#0F6B73', fontWeight: 700 }}>
✓ شما در حال حاضر عضو ویژه هستید
{status?.expiresAt && ` — اعتبار تا ${new Date(status.expiresAt).toLocaleDateString('fa-IR')}`}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(240px,1fr))', gap: 16, marginBottom: 32 }}>
{[
['👑', 'بدون تبلیغ', 'خواندن آرام و سریع‌تر بدون جایگاه‌های تبلیغاتی.'],
['📖', 'محتوای اختصاصی', 'تحلیل‌های عمیق‌تر و پرونده‌های تخصصی برای اعضا.'],
['🎓', 'تخفیف دوره‌ها', 'دسترسی زودتر و تخفیف روی دوره‌های فارسی تکناو.'],
].map(([icon, title, body]) => (
<section key={title} style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20 }}>
<div style={{ fontSize: 24, marginBottom: 8 }}>{icon}</div>
<h2 style={{ margin: '0 0 8px', fontSize: 18, color: '#263238' }}>{title}</h2>
<p style={{ margin: 0, color: '#5F6B6D', lineHeight: 1.8 }}>{body}</p>
</section>
))}
</div>
{!isPremium && (
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 16 }}>
سالانه — ۵۰۰,۰۰۰ تومان
</div>
<button onClick={subscribe} disabled={loading} style={{
background: 'linear-gradient(135deg, #D49A2A, #B8821E)', color: '#fff',
border: 'none', borderRadius: 10, padding: '14px 36px', fontSize: 16, fontWeight: 700,
cursor: loading ? 'not-allowed' : 'pointer', opacity: loading ? 0.7 : 1,
fontFamily: 'Vazirmatn,sans-serif',
}}>
{loading ? 'در حال اتصال به درگاه...' : 'شروع عضویت ویژه'}
</button>
</div>
)}
</div>
</div>
);
}
function MembershipSuccessPage() {
const { navigate } = useNav();
return (
<div style={{ paddingTop: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', maxWidth: 480, padding: '0 24px' }}>
<div style={{ fontSize: 56, marginBottom: 16 }}>👑</div>
<h1 style={{ fontSize: 26, fontWeight: 900, color: '#263238', marginBottom: 12 }}>عضویت ویژه فعال شد!</h1>
<p style={{ color: '#5F6B6D', lineHeight: 1.9, marginBottom: 28 }}>از حالا به همه مقالات پریمیوم دسترسی دارید. ممنون از حمایت شما از تکناو.</p>
<button onClick={() => navigate('/')} style={{ ...pillBtn, background: '#0F6B73', color: '#fff', padding: '12px 28px', fontSize: 14 }}>بازگشت به خانه</button>
</div>
</div>
);
}
// ── Shared legal page shell ──────────────────────────────────────────────────
function LegalPage({ title, subtitle, children }) {
return (
<div style={{ paddingTop: 88, paddingBottom: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<div style={{ maxWidth: 820, margin: '0 auto', padding: '0 24px' }}>
<div style={{ marginBottom: 40 }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: '#0F2A2E', marginBottom: 8, fontFamily: "'PelakFA','Vazirmatn',sans-serif" }}>{title}</h1>
{subtitle && <p style={{ fontSize: 14, color: '#5F6B6D', lineHeight: 1.8 }}>{subtitle}</p>}
<div style={{ height: 3, width: 48, background: '#D49A2A', borderRadius: 2, marginTop: 16 }} />
</div>
<div style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: '36px 40px', lineHeight: 1.95, color: '#374549', fontSize: 14 }}>
{children}
</div>
</div>
</div>
);
}
function LegalSection({ title, children }) {
return (
<section style={{ marginBottom: 32 }}>
<h2 style={{ fontSize: 16, fontWeight: 700, color: '#0F2A2E', marginBottom: 10, borderRight: '3px solid #0F6B73', paddingRight: 10 }}>{title}</h2>
{children}
</section>
);
}
// ── Privacy Policy ───────────────────────────────────────────────────────────
function PrivacyPolicyPage() {
return (
<LegalPage
title="قوانین حریم خصوصی"
subtitle="آخرین بروزرسانی: اردیبهشت ۱۴۰۵ — لطفاً این سیاست را پیش از استفاده از خدمات تکناو مطالعه کنید."
>
<LegalSection title="۱. اطلاعاتی که جمع‌آوری می‌کنیم">
<p style={{ marginBottom: 10 }}>تکناو اطلاعات زیر را از کاربران جمع‌آوری می‌کند:</p>
<ul style={{ paddingRight: 20, margin: 0, listStyle: 'disc' }}>
<li style={{ marginBottom: 6 }}><strong>اطلاعات حساب کاربری:</strong> نام، آدرس ایمیل و نام کاربری هنگام ثبت‌نام.</li>
<li style={{ marginBottom: 6 }}><strong>اطلاعات استفاده:</strong> صفحات بازدیدشده، مقالات مطالعه‌شده، مدت زمان حضور در سایت و واکنش‌های کاربری — به‌صورت ناشناس و جمعی.</li>
<li style={{ marginBottom: 6 }}><strong>اطلاعات فنی:</strong> نوع مرورگر، سیستم‌عامل و آدرس IP به‌منظور امنیت و رفع اشکال.</li>
<li style={{ marginBottom: 6 }}><strong>اطلاعات خبرنامه:</strong> آدرس ایمیل در صورت عضویت اختیاری در خبرنامه.</li>
</ul>
</LegalSection>
<LegalSection title="۲. نحوه استفاده از اطلاعات">
<p>اطلاعات جمع‌آوری‌شده صرفاً برای موارد زیر استفاده می‌شود:</p>
<ul style={{ paddingRight: 20, margin: '8px 0 0', listStyle: 'disc' }}>
<li style={{ marginBottom: 6 }}>ارائه و بهبود محتوا و خدمات تکناو</li>
<li style={{ marginBottom: 6 }}>ارسال خبرنامه در صورت اشتراک داوطلبانه</li>
<li style={{ marginBottom: 6 }}>تشخیص و پیشگیری از دسترسی غیرمجاز و سوءاستفاده</li>
<li style={{ marginBottom: 6 }}>پاسخ به درخواست‌ها و پشتیبانی کاربران</li>
</ul>
<p style={{ marginTop: 10 }}>تکناو اطلاعات شخصی شما را به هیچ شخص ثالثی نمی‌فروشد.</p>
</LegalSection>
<LegalSection title="۳. کوکی‌ها و ذخیره‌سازی محلی">
<p>تکناو از کوکی‌های ضروری برای مدیریت نشست (session) و از localStorage برای ذخیره تنظیمات رابط کاربری استفاده می‌کند. هیچ کوکی تبلیغاتی یا ردیابی شخص‌ثالث استفاده نمی‌شود.</p>
</LegalSection>
<LegalSection title="۴. امنیت اطلاعات">
<p>اطلاعات کاربران با رمزنگاری Argon2id (کلمه عبور) و HTTPS (انتقال داده) محافظت می‌شوند. دسترسی به داده‌های شخصی به حداقل افراد ضروری محدود است.</p>
</LegalSection>
<LegalSection title="۵. حقوق کاربر">
<p>شما حق دارید هر زمان درخواست دسترسی، اصلاح، یا حذف اطلاعات شخصی خود را از طریق <strong>info@teknav.ir</strong> ارسال کنید. درخواست‌ها ظرف ۱۴ روز کاری پردازش می‌شوند.</p>
</LegalSection>
<LegalSection title="۶. تغییرات در این سیاست">
<p>تکناو ممکن است این سیاست را به‌روزرسانی کند. تغییرات مهم از طریق اعلان درون‌سایتی اطلاع‌رسانی خواهند شد. ادامه استفاده از سایت پس از انتشار تغییرات به‌منزله پذیرش آن‌ها است.</p>
</LegalSection>
<LegalSection title="۷. تماس">
<p>برای سوالات مرتبط با حریم خصوصی با ما از طریق <strong>info@teknav.ir</strong> تماس بگیرید.</p>
</LegalSection>
</LegalPage>
);
}
// ── Terms of Use ─────────────────────────────────────────────────────────────
function TermsOfUsePage() {
return (
<LegalPage
title="شرایط استفاده"
subtitle="آخرین بروزرسانی: اردیبهشت ۱۴۰۵ — استفاده از وب‌سایت تکناو به معنای پذیرش این شرایط است."
>
<LegalSection title="۱. پذیرش شرایط">
<p>با دسترسی به وب‌سایت تکناو (teknav.ir) شما موافقت خود را با این شرایط اعلام می‌کنید. اگر با هر بخشی از این شرایط موافق نیستید، لطفاً از استفاده از سایت خودداری کنید.</p>
</LegalSection>
<LegalSection title="۲. محتوا و مالکیت معنوی">
<p>تمامی محتوای منتشرشده در تکناو — شامل مقالات، تحلیل‌ها، نمودارها، و طراحی‌ها — متعلق به تکناو یا نویسندگان مربوطه است و تحت قوانین مالکیت معنوی محافظت می‌شود.</p>
<p style={{ marginTop: 8 }}>نقل‌قول کوتاه با ذکر منبع (لینک مستقیم به مقاله) مجاز است. بازنشر کامل یا ترجمه بدون اجازه کتبی ممنوع است.</p>
</LegalSection>
<LegalSection title="۳. حساب کاربری">
<ul style={{ paddingRight: 20, margin: 0, listStyle: 'disc' }}>
<li style={{ marginBottom: 6 }}>مسئولیت حفظ امنیت اطلاعات ورود به سیستم با کاربر است.</li>
<li style={{ marginBottom: 6 }}>ایجاد چندین حساب کاربری توسط یک شخص مجاز نیست.</li>
<li style={{ marginBottom: 6 }}>تکناو حق تعلیق یا حذف حساب‌های ناقض قوانین را دارد.</li>
</ul>
</LegalSection>
<LegalSection title="۴. رفتار کاربران">
<p>کاربران متعهد می‌شوند که از سایت برای موارد زیر استفاده نکنند:</p>
<ul style={{ paddingRight: 20, margin: '8px 0 0', listStyle: 'disc' }}>
<li style={{ marginBottom: 6 }}>انتشار محتوای توهین‌آمیز، خلاف قانون، یا گمراه‌کننده</li>
<li style={{ marginBottom: 6 }}>ارسال هرزنامه (spam) یا تلاش برای دسترسی غیرمجاز</li>
<li style={{ marginBottom: 6 }}>تداخل در عملکرد سیستم یا سرویس‌های تکناو</li>
</ul>
</LegalSection>
<LegalSection title="۵. سلب مسئولیت">
<p>محتوای تکناو صرفاً جنبه اطلاع‌رسانی و آموزشی دارد. تکناو در قبال تصمیماتی که بر اساس محتوای منتشرشده اتخاذ می‌شوند مسئولیتی نمی‌پذیرد. صحت اطلاعات فنی در حد توان بررسی می‌شود اما ضمانت کامل داده نمی‌شود.</p>
</LegalSection>
<LegalSection title="۶. تغییر خدمات">
<p>تکناو حق دارد هر زمان خدمات، محتوا، یا این شرایط را تغییر دهد. اطلاع‌رسانی تغییرات مهم از طریق سایت انجام می‌شود.</p>
</LegalSection>
<LegalSection title="۷. قانون حاکم">
<p>این شرایط تابع قوانین جمهوری اسلامی ایران است. هرگونه اختلاف در صلاحیت دادگاه‌های ایران رسیدگی خواهد شد.</p>
</LegalSection>
<LegalSection title="۸. تماس">
<p>برای سوالات حقوقی با ما از طریق <strong>info@teknav.ir</strong> در ارتباط باشید.</p>
</LegalSection>
</LegalPage>
);
}
// ── About Page ───────────────────────────────────────────────────────────────
function AboutPage() {
const { navigate } = useNav();
return (
<LegalPage
title="درباره تکناو"
subtitle="روایت عمیق فناوری — تحلیل‌های علمی و تخصصی برای متخصصان ایرانی"
>
<LegalSection title="تکناو چیست؟">
<p>تکناو یک نشریه فناوری فارسی‌زبان است که با هدف تولید محتوای عمیق، علمی، و کاربردی برای متخصصان، پژوهشگران، و علاقه‌مندان ایرانی فعالیت می‌کند. ما باور داریم که جامعه فارسی‌زبان به منابع تخصصی‌ای نیاز دارد که نه صرفاً ترجمه، بلکه تحلیل و تولید اصیل باشند.</p>
</LegalSection>
<LegalSection title="حوزه‌های تخصصی">
<ul style={{ paddingRight: 20, margin: 0, listStyle: 'none' }}>
{[
['هوش مصنوعی', 'یادگیری ماشین، مدل‌های زبانی بزرگ، سیستم‌های هوشمند'],
['علم داده', 'مهندسی داده، پایپ‌لاین‌ها، ارزیابی کمی'],
['امنیت سایبری', 'تهدیدات نوظهور، رمزنگاری، تست نفوذ'],
['سخت‌افزار', 'پردازنده‌ها، شتاب‌دهنده‌های هوش مصنوعی، زیرساخت'],
['نرم‌افزار', 'معماری سیستم، مهندسی نرم‌افزار، ابزارهای توسعه'],
['آینده فناوری', 'روندهای بلندمدت، تأثیر اجتماعی فناوری'],
].map(([t, d]) => (
<li key={t} style={{ marginBottom: 10, display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{ minWidth: 8, height: 8, borderRadius: '50%', background: '#0F6B73', marginTop: 6, flexShrink: 0, display: 'inline-block' }} />
<span><strong>{t}:</strong> {d}</span>
</li>
))}
</ul>
</LegalSection>
<LegalSection title="رویکرد ما">
<p>هر مقاله تکناو با بررسی منابع اولیه، مقالات علمی، و تجربه عملی تهیه می‌شود. ما از هوش مصنوعی به‌عنوان ابزار کمکی در ویرایش استفاده می‌کنیم اما تحلیل، نتیجه‌گیری، و دیدگاه‌ها کاملاً انسانی هستند.</p>
</LegalSection>
<LegalSection title="تیم">
<p>تکناو توسط <strong>آرسام صباغ</strong> — پژوهشگر ارشد هوش مصنوعی و مهندسی داده — بنیان‌گذاری شده و با همکاری نویسندگان متخصص در حوزه‌های مختلف فناوری فعالیت می‌کند.</p>
<div style={{ marginTop: 16, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
<button onClick={() => navigate('/authors')} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 20px', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
مشاهده تیم نویسندگان
</button>
<button onClick={() => navigate('/contact')} style={{ background: 'transparent', color: '#0F6B73', border: '1px solid #0F6B73', borderRadius: 8, padding: '10px 20px', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
تماس با ما
</button>
</div>
</LegalSection>
<LegalSection title="اطلاعات تماس">
<p><strong>وب‌سایت:</strong> teknav.ir</p>
<p><strong>ایمیل:</strong> info@teknav.ir</p>
<p><strong>تهران، ایران</strong></p>
</LegalSection>
</LegalPage>
);
}
// ── Contact Page ─────────────────────────────────────────────────────────────
function ContactPage() {
const { navigate } = useNav();
const toast = useToast();
const [form, setForm] = useState({ name: '', email: '', subject: '', message: '' });
const [sent, setSent] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const submit = async (e) => {
e.preventDefault();
if (!form.name.trim() || !form.email.includes('@') || !form.message.trim()) {
toast('لطفاً همه فیلدهای ضروری را پر کنید', 'error');
return;
}
try {
await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
} catch {}
setSent(true);
};
if (sent) return (
<div style={{ paddingTop: 88, paddingBottom: 80, background: '#FAF7F0', minHeight: '100vh', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', maxWidth: 420, padding: '0 24px' }}>
<div style={{ fontSize: 56, marginBottom: 16 }}>✓</div>
<h2 style={{ fontSize: 22, fontWeight: 800, color: '#0F2A2E', marginBottom: 10 }}>پیام شما ارسال شد</h2>
<p style={{ color: '#5F6B6D', lineHeight: 1.8, marginBottom: 24 }}>ممنون از تماس شما. تیم تکناو به‌زودی پاسخ خواهد داد.</p>
<button onClick={() => navigate('/')} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
بازگشت به خانه
</button>
</div>
</div>
);
const field = { background: '#FAF7F0', border: '1px solid #D8D0C4', borderRadius: 8, padding: '11px 14px', fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', color: '#263238', width: '100%', boxSizing: 'border-box', outline: 'none', direction: 'rtl' };
return (
<LegalPage title="تماس با ما" subtitle="سوال، پیشنهاد، یا همکاری؟ خوشحال می‌شویم بشنویم.">
<LegalSection title="راه‌های ارتباطی مستقیم">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(200px,1fr))', gap: 16, marginBottom: 4 }}>
{[
['📧', 'ایمیل عمومی', 'info@teknav.ir'],
['✍️', 'همکاری محتوایی', 'admin@teknavglobal.com'],
['📍', 'موقعیت', 'تهران، ایران'],
].map(([icon, label, val]) => (
<div key={label} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: '16px 18px' }}>
<div style={{ fontSize: 22, marginBottom: 6 }}>{icon}</div>
<div style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#0F2A2E' }}>{val}</div>
</div>
))}
</div>
</LegalSection>
<LegalSection title="ارسال پیام">
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>نام <span style={{ color: '#C46A4D' }}>*</span></label>
<input value={form.name} onChange={e => set('name', e.target.value)} placeholder="نام شما" style={field} />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>ایمیل <span style={{ color: '#C46A4D' }}>*</span></label>
<input value={form.email} onChange={e => set('email', e.target.value)} placeholder="example@email.com" style={{ ...field, direction: 'ltr' }} />
</div>
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>موضوع</label>
<input value={form.subject} onChange={e => set('subject', e.target.value)} placeholder="موضوع پیام" style={field} />
</div>
<div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>پیام <span style={{ color: '#C46A4D' }}>*</span></label>
<textarea value={form.message} onChange={e => set('message', e.target.value)} placeholder="پیام خود را اینجا بنویسید..." rows={5} style={{ ...field, resize: 'vertical', lineHeight: 1.8 }} />
</div>
<button type="submit" style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', alignSelf: 'flex-start' }}>
ارسال پیام
</button>
</form>
</LegalSection>
</LegalPage>
);
}
// ── Tools Page ───────────────────────────────────────────────────────────────
function ToolsPage() {
const { navigate } = useNav();
return (
<LegalPage title="ابزارهای تخصصی توسعه‌دهندگان" subtitle="ابزارهای کمکی و محاسباتی برای پردازش زبان طبیعی فارسی (NLP)، هوش مصنوعی و بازیابی اطلاعات (RAG).">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 20 }}>
{[
{
title: 'نرمال‌ساز متن فارسی (NLP)',
desc: 'اصلاح حروف عربی، تنظیم نیم‌فاصله‌ها، حذف کدهای HTML و فاصله‌های مکرر در متون فارسی برای پیش‌پردازش سیستم‌های هوش مصنوعی.',
path: '/tools/nlp-normalizer',
icon: '📝',
},
{
title: 'محاسبه‌گر چانک و زمینه RAG',
desc: 'شبیه‌سازی تکه‌بندی متون، همپوشانی (Overlap)، محاسبه تعداد چانک‌ها و برآورد تعداد توکن‌های فارسی برای پایپ‌لاین‌های Retrieval-Augmented Generation.',
path: '/tools/rag-calculator',
icon: '📐',
},
].map((t) => (
<div key={t.path} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 12, padding: 24, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<div style={{ fontSize: 32, marginBottom: 12 }}>{t.icon}</div>
<h2 style={{ fontSize: 17, fontWeight: 800, color: '#0F2A2E', marginBottom: 10 }}>{t.title}</h2>
<p style={{ color: '#5F6B6D', fontSize: 13, lineHeight: 1.8, marginBottom: 20 }}>{t.desc}</p>
</div>
<button onClick={() => navigate(t.path)} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', width: '100%' }}>
ورود به ابزار
</button>
</div>
))}
</div>
</LegalPage>
);
}
// ── NLP Normalizer Page ──────────────────────────────────────────────────────
function NlpNormalizerPage() {
const { navigate } = useNav();
const toast = useToast();
const [text, setText] = useState('');
const [normalized, setNormalized] = useState('');
const [options, setOptions] = useState({
stripHtml: true,
normalizeLetters: true,
normalizeZwnj: true,
cleanWhitespace: true,
});
const toggle = (k) => setOptions((o) => ({ ...o, [k]: !o[k] }));
const handleNormalize = () => {
let t = text || '';
if (options.stripHtml) {
t = t.replace(/<[^>]*>/g, ' ');
}
if (options.normalizeLetters) {
t = t.replace(/ي/g, 'ی').replace(/ك/g, 'ک').replace(/ة/g, 'ه');
}
if (options.normalizeZwnj) {
t = t.replace(/\s+(می|نمی)\s+/g, ' $1‌');
t = t.replace(/\s+(تر|ترین|ها|های|ام|ات|اش|مان|تان|شان)\b/g, '‌$1');
t = t.replace(/\b(می|نمی)\b\s+/g, '$1‌');
}
if (options.cleanWhitespace) {
t = t.replace(/\s+/g, ' ');
t = t.replace(/\n\s*\n+/g, '\n\n');
}
setNormalized(t.trim());
toast('متن با موفقیت نرمال‌سازی شد', 'success');
};
const copyToClipboard = (val) => {
if (!val) return;
navigator.clipboard.writeText(val);
toast('کپی شد!', 'success');
};
const textareaStyle = {
width: '100%',
minHeight: 180,
background: '#FAF7F0',
border: '1px solid #D8D0C4',
borderRadius: 8,
padding: '12px 16px',
fontSize: 14,
fontFamily: 'Vazirmatn, sans-serif',
color: '#263238',
outline: 'none',
lineHeight: 1.7,
resize: 'vertical',
boxSizing: 'border-box',
};
return (
<LegalPage title="نرمال‌ساز متن فارسی (NLP)" subtitle="اصلاح نویسه‌ها، تنظیم فاصله‌ها و بهینه‌سازی متون فارسی برای ابزارهای پردازش زبان طبیعی و مدل‌های زبانی.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 700, color: '#0F2A2E', display: 'block', marginBottom: 8 }}>متن ورودی:</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="متن فارسی خود را برای نرمال‌سازی اینجا وارد کنید..."
style={textareaStyle}
/>
</div>
<div style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18 }}>
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 12 }}>تنظیمات نرمال‌سازی</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 12 }}>
{[
['stripHtml', 'حذف تگ‌های HTML'],
['normalizeLetters', 'تبدیل ی و ک عربی به فارسی'],
['normalizeZwnj', 'تنظیم نیم‌فاصله‌های پیشوند/پسوند'],
['cleanWhitespace', 'اصلاح فاصله‌ها و تب‌های مکرر'],
].map(([k, label]) => (
<label key={k} style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13, color: '#374549' }}>
<input type="checkbox" checked={options[k]} onChange={() => toggle(k)} style={{ accentColor: '#0F6B73' }} />
<span>{label}</span>
</label>
))}
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={handleNormalize} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
نرمال‌سازی متن
</button>
<button onClick={() => navigate('/tools')} style={{ background: 'transparent', color: '#5F6B6D', border: '1px solid #D8D0C4', borderRadius: 8, padding: '12px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
بازگشت
</button>
</div>
{normalized && (
<div style={{ marginTop: 10 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<label style={{ fontSize: 13, fontWeight: 700, color: '#0F2A2E' }}>متن خروجی:</label>
<button onClick={() => copyToClipboard(normalized)} style={{ background: '#FAF7F0', border: '1px solid #D8D0C4', borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif', display: 'flex', alignItems: 'center', gap: 6 }}>
📋 کپی خروجی
</button>
</div>
<textarea
readOnly
value={normalized}
style={{ ...textareaStyle, background: '#fff' }}
/>
</div>
)}
<div style={{ borderTop: '1px solid #E4DDD2', paddingTop: 20, marginTop: 10 }}>
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 8 }}>ارجاع علمی و اشتراک‌گذاری</h3>
<p style={{ color: '#5F6B6D', fontSize: 12, lineHeight: 1.7, marginBottom: 12 }}>برای استفاده از خروجی‌های این ابزار در تحقیقات یا کارهای متن‌باز خود، می‌توانید از فرمت ارجاع زیر استفاده کنید:</p>
<div style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 8, padding: 12, fontSize: 11, fontFamily: 'monospace', direction: 'ltr', textAlign: 'left', overflowX: 'auto', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<code style={{ color: '#0F2A2E' }}>[Teknav Persian NLP Tool] (https://www.teknav.ir/tools/nlp-normalizer)</code>
<button onClick={() => copyToClipboard('[Teknav Persian NLP Tool] (https://www.teknav.ir/tools/nlp-normalizer)')} style={{ background: '#fff', border: '1px solid #D8D0C4', borderRadius: 4, padding: '2px 8px', fontSize: 10, cursor: 'pointer', fontFamily: 'sans-serif' }}>Copy</button>
</div>
</div>
</div>
</LegalPage>
);
}
// ── RAG Calculator Page ──────────────────────────────────────────────────────
function RagCalculatorPage() {
const { navigate } = useNav();
const toast = useToast();
const [text, setText] = useState('');
const [chunkSize, setChunkSize] = useState(500);
const [overlap, setOverlap] = useState(50);
const [chunks, setChunks] = useState([]);
const handleCalculate = () => {
if (!text.trim()) {
toast('لطفاً ابتدا متنی را برای چانک‌بندی وارد کنید', 'error');
return;
}
if (chunkSize <= 0 || overlap < 0 || overlap >= chunkSize) {
toast('اندازه چانک یا همپوشانی نامعتبر است', 'error');
return;
}
const t = text.trim();
const result = [];
let start = 0;
while (start < t.length) {
let end = start + chunkSize;
if (end > t.length) end = t.length;
const content = t.substring(start, end);
const estimatedTokens = Math.round(content.length / 3.8);
result.push({
id: result.length + 1,
content,
charCount: content.length,
tokenCount: estimatedTokens,
range: [start, end],
});
start += (chunkSize - overlap);
if (start >= t.length || end === t.length) break;
}
setChunks(result);
toast(`تکه‌بندی با موفقیت انجام شد (${result.length} چانک)`, 'success');
};
const copyToClipboard = (val) => {
if (!val) return;
navigator.clipboard.writeText(val);
toast('کپی شد!', 'success');
};
return (
<LegalPage title="محاسبه‌گر چانک و زمینه RAG" subtitle="شبیه‌سازی اندازه تکه‌ها (Chunks)، همپوشانی (Overlap) و تخمین توکن برای سیستم‌های پرسش و پاسخ با بازیابی اطلاعات.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 700, color: '#0F2A2E', display: 'block', marginBottom: 8 }}>متن نمونه:</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="متن فارسی مورد نظر برای شبیه‌سازی RAG را در این قسمت بنویسید..."
style={{ width: '100%', minHeight: 140, background: '#FAF7F0', border: '1px solid #D8D0C4', borderRadius: 8, padding: '12px 16px', fontSize: 14, fontFamily: 'Vazirmatn, sans-serif', color: '#263238', outline: 'none', lineHeight: 1.7, resize: 'vertical', boxSizing: 'border-box' }}
/>
</div>
<div style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18 }}>
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 12 }}>پارامترهای شبیه‌سازی</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16 }}>
<div>
<label style={{ fontSize: 12, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>اندازه هر تکه (به کاراکتر):</label>
<input type="number" value={chunkSize} onChange={(e) => setChunkSize(Number(e.target.value))} style={{ width: '100%', padding: '10px 12px', border: '1px solid #D8D0C4', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn, sans-serif' }} />
</div>
<div>
<label style={{ fontSize: 12, color: '#5F6B6D', display: 'block', marginBottom: 5 }}>اندازه همپوشانی (Overlap):</label>
<input type="number" value={overlap} onChange={(e) => setOverlap(Number(e.target.value))} style={{ width: '100%', padding: '10px 12px', border: '1px solid #D8D0C4', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn, sans-serif' }} />
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 12 }}>
<button onClick={handleCalculate} style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '12px 28px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
محاسبه و شبیه‌سازی
</button>
<button onClick={() => navigate('/tools')} style={{ background: 'transparent', color: '#5F6B6D', border: '1px solid #D8D0C4', borderRadius: 8, padding: '12px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'Vazirmatn,sans-serif' }}>
بازگشت
</button>
</div>
{chunks.length > 0 && (
<div style={{ marginTop: 10 }}>
<h3 style={{ fontSize: 15, fontWeight: 800, color: '#0F2A2E', marginBottom: 12 }}>نتایج شبیه‌سازی تکه‌بندی:</h3>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 }}>
{[
['طول کل کاراکترها', text.length],
['تعداد تخمینی توکن‌ها', Math.round(text.length / 3.8)],
['تعداد چانک‌های خروجی', chunks.length],
].map(([lbl, val]) => (
<div key={lbl} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 8, padding: 12, textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#5F6B6D', marginBottom: 4 }}>{lbl}</div>
<div style={{ fontSize: 16, fontWeight: 800, color: '#0F2A2E' }}>{val.toLocaleString('fa-IR')}</div>
</div>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{chunks.map((c) => (
<div key={c.id} style={{ border: '1px solid #E4DDD2', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ background: '#FAF7F0', borderBottom: '1px solid #E4DDD2', padding: '10px 16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 12, fontWeight: 800, color: '#0F2A2E' }}>چانک #{c.id.toLocaleString('fa-IR')} (اندیس {c.range[0].toLocaleString('fa-IR')} تا {c.range[1].toLocaleString('fa-IR')})</span>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#5F6B6D' }}>{c.charCount.toLocaleString('fa-IR')} کاراکتر · {c.tokenCount.toLocaleString('fa-IR')} توکن تخمینی</span>
<button onClick={() => copyToClipboard(c.content)} style={{ background: '#fff', border: '1px solid #D8D0C4', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer', fontFamily: 'Vazirmatn, sans-serif' }}>کپی</button>
</div>
</div>
<div style={{ padding: 16, fontSize: 13, lineHeight: 1.8, color: '#263238', background: '#fff', whiteSpace: 'pre-wrap' }}>
{c.content}
</div>
</div>
))}
</div>
</div>
)}
</div>
</LegalPage>
);
}
// ── TekRAG Persian Hub Page ──────────────────────────────────────────────────
function TekRagHubPage() {
const toast = useToast();
const copyToClipboard = (val) => {
navigator.clipboard.writeText(val);
toast('کپی شد!', 'success');
};
const citationText = `@article{tekrag_persian_2026,
title={TekRAG-Persian: A Data-Centric Evaluation Framework for Hallucination in Persian Retrieval-Augmented Generation},
author={Sabbagh, Arsam},
journal={Teknav Journal of AI Research},
volume={1},
number={1},
pages={10-24},
year={2026},
publisher={Teknav Media}
}`;
return (
<LegalPage title="مجموعه داده و بنچمارک TekRAG-Persian" subtitle="چارچوب داده‌محور برای ارزیابی پدیده توهم (Hallucination) در سیستم‌های بازیابی اطلاعات فارسی.">
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<LegalSection title="معرفی چارچوب پژوهش">
<p style={{ margin: '0 0 12px', lineHeight: 1.8 }}><strong>TekRAG-Persian</strong> اولین مجموعه داده و چارچوب بنچمارک متمرکز بر زبان فارسی برای سنجش و کاهش میزان توهم در پیاده‌سازی‌های RAG است. این پروژه بر مبنای تحلیل مقاله‌های تخصصی رسانه تکناو و پاسخ‌دهی دقیق مدل‌های زبانی بزرگ توسعه داده شده است.</p>
<p style={{ margin: 0, lineHeight: 1.8 }}>هدف این بنچمارک ارزیابی سه متغیر کلیدی در بهبود عملکرد سیستم‌های بازیابی است: پاک‌سازی پیش‌رفته متن، افزودن متادیتاهای ساختاریافته به تکه‌ها، و تأثیر هدایت مدل با تأکید بر استناد به منابع اصلی.</p>
</LegalSection>
<LegalSection title="شناسنامه و لینک‌های دسترسی">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 16 }}>
{[
['💾 مجموعه داده در Hugging Face', 'دسترسی به فایل‌های خام متون و پرسش‌ها', 'https://huggingface.co/datasets/teknav/tekrag-persian', 'مشاهده دیتاست'],
['🔗 شناسه دیجیتال Zenodo (DOI)', 'نسخه آرشیو شده و معتبر علمی پژوهش', 'https://doi.org/10.5281/zenodo.placeholder', 'مشاهده DOI'],
['⚙️ سورس‌کد در GitHub', 'کدهای پیاده‌سازی و ارزیابی بنچمارک', 'https://github.com/teknav/tekrag-persian', 'مشاهده ریپازیتوری'],
].map(([title, desc, link, btnText]) => (
<div key={title} style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: 18, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
<div>
<h3 style={{ fontSize: 14, fontWeight: 800, color: '#0F2A2E', marginBottom: 6 }}>{title}</h3>
<p style={{ fontSize: 12, color: '#5F6B6D', margin: '0 0 16px', lineHeight: 1.6 }}>{desc}</p>
</div>
<a href={link} target="_blank" rel="noopener noreferrer" style={{ display: 'block', textAlign: 'center', background: '#0F6B73', color: '#fff', textDecoration: 'none', borderRadius: 6, padding: '8px 14px', fontSize: 12, fontWeight: 600, fontFamily: 'Vazirmatn,sans-serif' }}>
{btnText}
</a>
</div>
))}
</div>
</LegalSection>
<LegalSection title="نحوه ارجاع علمی (Citation)">
<p style={{ margin: '0 0 12px', lineHeight: 1.8 }}>اگر از این بنچمارک یا دیتاست در پژوهش‌های دانشگاهی یا پروژه‌های علمی خود استفاده می‌کنید، لطفاً با فرمت BibTeX زیر به آن ارجاع دهید:</p>
<div style={{ position: 'relative' }}>
<button onClick={() => copyToClipboard(citationText)} style={{ position: 'absolute', top: 12, left: 12, background: '#fff', border: '1px solid #D8D0C4', borderRadius: 6, padding: '5px 10px', fontSize: 11, cursor: 'pointer', fontFamily: 'Vazirmatn, sans-serif' }}>
📋 کپی ارجاع
</button>
<pre style={{ background: '#FAF7F0', border: '1px solid #E4DDD2', borderRadius: 10, padding: '44px 20px 20px', fontSize: 12, fontFamily: 'monospace', direction: 'ltr', textAlign: 'left', overflowX: 'auto', margin: 0, lineHeight: 1.6 }}>
{citationText}
</pre>
</div>
</LegalSection>
<LegalSection title="ساختار مجموعه داده">
<p style={{ margin: '0 0 16px', lineHeight: 1.8 }}>این بنچمارک شامل بخش‌های زیر در فرمت JSON است:</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{[
['Corpus (بدنه داده)', 'شامل مقاله‌های تخصصی با تگ‌ها، دسته‌بندی، پاراگراف‌ها و عناوین مجزا.'],
['Questions (سوالات)', '۲۰۰ سوال واقعی طراحی شده توسط متخصصان در سطوح مختلف دشواری.'],
['Gold Annotations (پاسخ‌های طلایی)', 'تکه‌های دقیق پشتیبان برای هر سوال جهت سنجش بازیابی درست اطلاعات.'],
].map(([t, d]) => (
<div key={t} style={{ display: 'flex', gap: 12, alignItems: 'flex-start', background: '#FAF7F020', border: '1px solid #E4DDD255', borderRadius: 8, padding: 12 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#0F6B73', minWidth: 140 }}>{t}</span>
<span style={{ fontSize: 13, color: '#5F6B6D' }}>{d}</span>
</div>
))}
</div>
</LegalSection>
</div>
</LegalPage>
);
}
function ComparisonVoteBar({ slug, item1, item2 }) {
const key = `cmp_vote_${slug}`;
const [vote, setVote] = useState(() => {
try { return localStorage.getItem(key) || null; } catch { return null; }
});
const [shared, setShared] = useState(false);
const cast = (choice) => {
try { localStorage.setItem(key, choice); } catch {}
setVote(choice);
};
const shareComparison = () => {
const url = `https://www.teknav.ir/compare/${slug}`;
if (navigator.share) {
navigator.share({ title: `مقایسه ${item1.name} در برابر ${item2.name} — تکناو`, url }).catch(() => {});
} else {
navigator.clipboard?.writeText(url).then(() => { setShared(true); setTimeout(() => setShared(false), 2000); }).catch(() => {});
}
};
return (
<div style={{ background: '#F8F5F0', borderRadius: 12, padding: '20px 24px', marginBottom: 32, display: 'flex', gap: 20, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 260 }}>
<p style={{ fontSize: 14, fontWeight: 700, color: '#263238', marginBottom: 12 }}>شما کدام را ترجیح می‌دهید؟</p>
<div style={{ display: 'flex', gap: 10 }}>
<button
onClick={() => cast('item1')}
style={{
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700, transition: 'all 0.15s',
background: vote === 'item1' ? '#0F6B73' : '#fff',
color: vote === 'item1' ? '#fff' : '#0F6B73',
border: `2px solid ${vote === 'item1' ? '#0F6B73' : '#E4DDD2'}`,
}}
>
{vote === 'item1' ? '✓ ' : ''}{item1.name}
</button>
<button
onClick={() => cast('item2')}
style={{
flex: 1, padding: '9px 16px', borderRadius: 8, fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700, transition: 'all 0.15s',
background: vote === 'item2' ? '#D49A2A' : '#fff',
color: vote === 'item2' ? '#fff' : '#D49A2A',
border: `2px solid ${vote === 'item2' ? '#D49A2A' : '#E4DDD2'}`,
}}
>
{vote === 'item2' ? '✓ ' : ''}{item2.name}
</button>
</div>
{vote && (
<p style={{ fontSize: 12, color: '#5F6B6D', marginTop: 10 }}>
انتخاب شما ثبت شد. برای تغییر دوباره کلیک کنید.
</p>
)}
</div>
<button
onClick={shareComparison}
style={{
background: shared ? '#2E7D32' : '#0C1E22', color: shared ? '#fff' : '#D49A2A',
border: 'none', borderRadius: 8, padding: '10px 20px', fontSize: 13,
fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700, whiteSpace: 'nowrap',
}}
>
{shared ? '✓ لینک کپی شد' : '🔗 اشتراک‌گذاری'}
</button>
</div>
);
}
function ComparisonPage({ slug }) {
const { navigate } = useNav();
const comparison = (TeknavData.comparisons ?? []).find((c) => c.slug === slug);
if (!comparison) {
return (
<div style={{ padding: '120px 24px', textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<EmptyState title="صفحه مقایسه یافت نشد" subtitle="مقایسه‌ای با این آدرس وجود ندارد" />
</div>
);
}
const { item1, item2, verdict, useCases = [], relatedArticles = [], relatedGlossary = [] } = comparison;
const articles = TeknavData.articles.filter((a) => relatedArticles.includes(a.slug));
const glossaryTerms = (TeknavData.glossary ?? []).filter((g) => relatedGlossary.includes(g.slug));
const cardStyle = {
background: '#fff', borderRadius: 12, padding: 24,
border: '1px solid #E4DDD2', flex: '1 1 300px',
};
const proStyle = { fontSize: 13, color: '#2E7D32', marginBottom: 6, display: 'flex', alignItems: 'flex-start', gap: 6 };
const conStyle = { fontSize: 13, color: '#C62828', marginBottom: 6, display: 'flex', alignItems: 'flex-start', gap: 6 };
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
{/* Breadcrumb */}
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
<span>›</span>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/compare')}>مقایسه‌ها</span>
<span>›</span>
<span>{comparison.title}</span>
</nav>
{/* Header */}
<h1 style={{ fontSize: 'clamp(22px,4vw,32px)', fontWeight: 800, color: '#0C1E22', lineHeight: 1.5, marginBottom: 12 }}>
{comparison.title}
</h1>
{comparison.metaDescription && (
<p style={{ fontSize: 16, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 16 }}>{comparison.metaDescription}</p>
)}
{comparison.factCheckedAt && (
<p style={{ fontSize: 12, color: '#5F6B6D', marginBottom: 24 }}>
آخرین بروزرسانی: {comparison.factCheckedAt}
</p>
)}
{/* Vote + Share bar */}
<ComparisonVoteBar slug={slug} item1={item1} item2={item2} />
{/* Two-column cards */}
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginBottom: 40 }}>
{[item1, item2].map((item) => (
<div key={item.slug} style={cardStyle}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: '#0F6B73', marginBottom: 10 }}>{item.name}</h2>
<p style={{ fontSize: 14, color: '#263238', lineHeight: 1.8, marginBottom: 16 }}>{item.summary}</p>
<div style={{ marginBottom: 8, fontSize: 13, fontWeight: 700, color: '#263238' }}>مزایا</div>
{(item.pros || []).map((p, i) => (
<div key={i} style={proStyle}><span>✓</span><span>{p}</span></div>
))}
<div style={{ marginBottom: 8, marginTop: 12, fontSize: 13, fontWeight: 700, color: '#263238' }}>معایب</div>
{(item.cons || []).map((c, i) => (
<div key={i} style={conStyle}><span>✗</span><span>{c}</span></div>
))}
</div>
))}
</div>
{/* Use Cases */}
{useCases.length > 0 && (
<section style={{ background: '#F8F5F0', borderRadius: 12, padding: 24, marginBottom: 32 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 16 }}>چه زمانی کدام را انتخاب کنیم؟</h2>
{useCases.map(([left, right], i) => (
<div key={i} style={{ display: 'flex', gap: 20, flexWrap: 'wrap' }}>
<div style={{ flex: '1 1 280px', padding: '12px 16px', background: '#fff', borderRadius: 8, border: '1px solid #E4DDD2', fontSize: 13, lineHeight: 1.7 }}>
<span style={{ fontWeight: 700, color: '#0F6B73' }}>{item1.name}:</span> {left}
</div>
<div style={{ flex: '1 1 280px', padding: '12px 16px', background: '#fff', borderRadius: 8, border: '1px solid #E4DDD2', fontSize: 13, lineHeight: 1.7 }}>
<span style={{ fontWeight: 700, color: '#0F6B73' }}>{item2.name}:</span> {right}
</div>
</div>
))}
</section>
)}
{/* Verdict */}
{verdict && (
<section style={{ background: '#0F3540', borderRadius: 12, padding: 24, marginBottom: 40, color: '#fff' }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#D49A2A', marginBottom: 12 }}>جمع‌بندی و نتیجه‌گیری</h2>
<p style={{ fontSize: 15, lineHeight: 1.9 }}>{verdict}</p>
</section>
)}
{/* Related Glossary */}
{glossaryTerms.length > 0 && (
<section style={{ marginBottom: 40 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 16 }}>اصطلاحات مرتبط</h2>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
{glossaryTerms.map((g) => (
<div
key={g.slug}
onClick={() => navigate(`/glossary/${g.slug}`)}
style={{ background: '#fff', border: '1px solid #0F6B73', borderRadius: 8, padding: '10px 16px', cursor: 'pointer' }}
>
<div style={{ fontSize: 14, fontWeight: 700, color: '#0F6B73' }}>{g.term}</div>
<div style={{ fontSize: 12, color: '#5F6B6D' }}>{g.english}</div>
</div>
))}
</div>
</section>
)}
{/* Related Articles */}
{articles.length > 0 && (
<section>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 16 }}>مقالات مرتبط</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{articles.map((a) => (
<div
key={a.id}
onClick={() => navigate(`/article/${a.slug}`)}
style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 8, padding: '14px 18px', cursor: 'pointer', display: 'flex', gap: 12, alignItems: 'flex-start' }}
>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 700, color: '#263238', marginBottom: 4 }}>{a.title}</div>
<div style={{ fontSize: 13, color: '#5F6B6D' }}>{a.summary?.slice(0, 100)}…</div>
</div>
<span style={{ fontSize: 20 }}>←</span>
</div>
))}
</div>
</section>
)}
</div>
);
}
function DirectoryListPage() {
const { navigate } = useNav();
const [companies] = useState(() => TeknavData.companies ?? []);
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return companies;
const q = search.trim().toLocaleLowerCase('fa-IR');
return companies.filter((c) =>
c.name.toLocaleLowerCase('fa-IR').includes(q) ||
c.description.toLocaleLowerCase('fa-IR').includes(q) ||
(c.techStack || []).some((t) => t.toLocaleLowerCase('en-US').includes(q)),
);
}, [companies, search]);
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
<span>›</span>
<span>دایرکتوری شرکت‌های فناوری</span>
</nav>
<h1 style={{ fontSize: 'clamp(22px,4vw,30px)', fontWeight: 800, color: '#0C1E22', marginBottom: 8 }}>
دایرکتوری شرکت‌های فناوری ایران
</h1>
<p style={{ fontSize: 15, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 28 }}>
پروفایل شرکت‌های فناوری ایران همراه با استک فنی، اطلاعات تماس و نشان تأیید.
</p>
<input
type="search"
placeholder="جست‌وجو در شرکت‌ها…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ ...inputStyle, marginBottom: 24, width: '100%', maxWidth: 400 }}
/>
{filtered.length === 0 && (
<EmptyState title="شرکتی یافت نشد" subtitle="جست‌وجوی دیگری امتحان کنید" />
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(280px,1fr))', gap: 16 }}>
{filtered.map((company) => (
<div
key={company.slug}
onClick={() => navigate(`/directory/${company.slug}`)}
style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: 20, cursor: 'pointer', transition: 'box-shadow 0.2s' }}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.1)'; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = ''; }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 10 }}>
<span style={{ fontSize: 18, fontWeight: 700, color: '#0C1E22' }}>{company.name}</span>
{company.verified && <span style={{ fontSize: 11, background: '#E8F5E9', color: '#2E7D32', padding: '2px 8px', borderRadius: 20, fontWeight: 600 }}>✓ تأیید</span>}
</div>
<p style={{ fontSize: 13, color: '#5F6B6D', lineHeight: 1.7, marginBottom: 12 }}>{company.description.slice(0, 100)}…</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{(company.techStack || []).slice(0, 4).map((t) => (
<span key={t} style={{ background: '#F4EFE6', color: '#263238', fontSize: 11, padding: '2px 8px', borderRadius: 20 }}>{t}</span>
))}
</div>
</div>
))}
</div>
</div>
);
}
function CompanyProfilePage({ slug }) {
const { navigate } = useNav();
const company = (TeknavData.companies ?? []).find((c) => c.slug === slug);
if (!company) {
return (
<div style={{ padding: '120px 24px', textAlign: 'center', fontFamily: 'Vazirmatn,sans-serif', direction: 'rtl' }}>
<EmptyState title="شرکت یافت نشد" subtitle="پروفایل شرکتی با این آدرس وجود ندارد" />
</div>
);
}
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8, alignItems: 'center' }}>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
<span>›</span>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/directory')}>دایرکتوری</span>
<span>›</span>
<span>{company.name}</span>
</nav>
<div style={{ background: '#0C1E22', borderRadius: 16, padding: '28px 28px', marginBottom: 28, display: 'flex', gap: 20, alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<h1 style={{ fontSize: 'clamp(20px,4vw,28px)', fontWeight: 800, color: '#fff', margin: 0 }}>{company.name}</h1>
{company.verified && (
<span style={{ background: '#D49A2A', color: '#0C1E22', fontSize: 11, fontWeight: 700, padding: '3px 10px', borderRadius: 20 }}>✓ تأیید شده</span>
)}
</div>
<p style={{ fontSize: 15, color: 'rgba(255,255,255,0.8)', lineHeight: 1.8, margin: 0 }}>{company.description}</p>
</div>
</div>
<div style={{ display: 'flex', gap: 20, flexWrap: 'wrap', marginBottom: 28 }}>
{company.websiteUrl && (
<a
href={company.websiteUrl}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, background: '#0F6B73', color: '#fff', padding: '10px 20px', borderRadius: 8, fontSize: 14, fontWeight: 700, textDecoration: 'none' }}
>
وب‌سایت ↗
</a>
)}
</div>
{(company.techStack || []).length > 0 && (
<section style={{ marginBottom: 28 }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: '#263238', marginBottom: 12 }}>استک فناوری</h2>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{company.techStack.map((t) => (
<span key={t} style={{ background: '#E4DDD2', color: '#263238', fontSize: 13, padding: '5px 14px', borderRadius: 20, fontWeight: 600 }}>{t}</span>
))}
</div>
</section>
)}
<div style={{ background: '#F8F5F0', borderRadius: 12, padding: 24 }}>
<p style={{ fontSize: 14, color: '#5F6B6D', margin: '0 0 12px' }}>
این صفحه بخشی از دایرکتوری شرکت‌های فناوری تکناو است. اگر این شرکت را نمایندگی می‌کنید، می‌توانید پروفایل آن را ادعا و تأیید کنید.
</p>
<button
style={{ background: '#0F6B73', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 20px', fontSize: 14, fontFamily: 'Vazirmatn,sans-serif', cursor: 'pointer', fontWeight: 700 }}
onClick={() => navigate('/login')}
>
ادعای پروفایل
</button>
</div>
</div>
);
}
const CATEGORY_COLORS = { ai: '#0F6B73', data: '#2F8F6B', security: '#C62828', software: '#1565C0', hardware: '#6A1B9A' };
function ComparisonsListPage() {
const { navigate } = useNav();
const comparisons = TeknavData.comparisons ?? [];
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return comparisons;
const q = search.trim().toLocaleLowerCase('fa-IR');
return comparisons.filter((c) =>
c.title.toLocaleLowerCase('fa-IR').includes(q) ||
c.item1.name.toLocaleLowerCase('en-US').includes(q) ||
c.item2.name.toLocaleLowerCase('en-US').includes(q) ||
(c.tags || []).some((t) => t.toLocaleLowerCase().includes(q)),
);
}, [comparisons, search]);
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8 }}>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
<span>›</span><span>مقایسه‌ها</span>
</nav>
<h1 style={{ fontSize: 'clamp(22px,4vw,32px)', fontWeight: 800, color: '#0C1E22', marginBottom: 8 }}>مقایسه‌های فنی تکناو</h1>
<p style={{ fontSize: 15, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 28 }}>
تحلیل‌های مقایسه‌ای جامع میان فناوری‌ها، فریم‌ورک‌ها و ابزارهای رایج — برای انتخاب بهتر در پروژه‌های فارسی‌زبان.
</p>
<input
type="search"
placeholder="جست‌وجو در مقایسه‌ها…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{ ...inputStyle, marginBottom: 28, width: '100%', maxWidth: 400 }}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{filtered.map((c) => (
<div
key={c.slug}
onClick={() => navigate(`/compare/${c.slug}`)}
style={{ background: '#fff', border: '1px solid #E4DDD2', borderRadius: 12, padding: '20px 24px', cursor: 'pointer', display: 'flex', gap: 20, alignItems: 'center' }}
onMouseEnter={(e) => { e.currentTarget.style.boxShadow = '0 4px 16px rgba(0,0,0,0.09)'; }}
onMouseLeave={(e) => { e.currentTarget.style.boxShadow = ''; }}
>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 8, flexWrap: 'wrap' }}>
<span style={{ background: CATEGORY_COLORS[c.category] || '#0F6B73', color: '#fff', fontSize: 11, fontWeight: 700, padding: '2px 10px', borderRadius: 20 }}>
{c.category}
</span>
{(c.tags || []).slice(0, 3).map((t) => (
<span key={t} style={{ background: '#F4EFE6', color: '#5F6B6D', fontSize: 11, padding: '2px 8px', borderRadius: 20 }}>{t}</span>
))}
</div>
<h2 style={{ fontSize: 17, fontWeight: 700, color: '#0C1E22', margin: '0 0 8px', lineHeight: 1.5 }}>{c.title}</h2>
<div style={{ display: 'flex', gap: 16, fontSize: 13 }}>
<span style={{ background: '#E8F5E9', color: '#2E7D32', padding: '3px 12px', borderRadius: 20, fontWeight: 600 }}>{c.item1.name}</span>
<span style={{ color: '#5F6B6D', alignSelf: 'center' }}>در برابر</span>
<span style={{ background: '#FFF3E0', color: '#E65100', padding: '3px 12px', borderRadius: 20, fontWeight: 600 }}>{c.item2.name}</span>
</div>
</div>
<span style={{ fontSize: 22, color: '#0F6B73' }}>←</span>
</div>
))}
{filtered.length === 0 && <EmptyState title="مقایسه‌ای یافت نشد" subtitle="جست‌وجوی دیگری امتحان کنید" />}
</div>
</div>
);
}
function StoriesListPage() {
const { navigate } = useNav();
const today = new Date().toISOString().slice(0, 10);
const stories = (TeknavData.articles ?? []).filter((a) => {
if (a.status !== 'منتشرشده' && a.status) return false;
const d = String(a.dateEn || '').slice(0, 10);
return /^\d{4}-\d{2}-\d{2}$/.test(d) && d <= today;
});
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '24px 16px 80px', direction: 'rtl', fontFamily: 'Vazirmatn,sans-serif' }}>
<nav style={{ fontSize: 13, color: '#5F6B6D', marginBottom: 24, display: 'flex', gap: 8 }}>
<span style={{ cursor: 'pointer', color: '#0F6B73' }} onClick={() => navigate('/')}>تکناو</span>
<span>›</span><span>استوری‌ها</span>
</nav>
<h1 style={{ fontSize: 'clamp(22px,4vw,32px)', fontWeight: 800, color: '#0C1E22', marginBottom: 8 }}>استوری‌های تکناو</h1>
<p style={{ fontSize: 15, color: '#5F6B6D', lineHeight: 1.8, marginBottom: 28 }}>
مقالات تکناو در قالب استوری‌های بصری — سریع، قابل اسوایپ و بهینه برای موبایل.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(160px,1fr))', gap: 14 }}>
{stories.map((a) => {
const img = a.ogImage || `/images/og/${a.slug}.jpg`;
return (
<div
key={a.id}
onClick={() => { window.open(`/stories/${a.slug}`, '_blank'); }}
style={{ borderRadius: 14, overflow: 'hidden', cursor: 'pointer', position: 'relative', paddingBottom: '177%', background: '#0C1E22' }}
onMouseEnter={(e) => { e.currentTarget.style.transform = 'scale(1.03)'; e.currentTarget.style.boxShadow = '0 8px 24px rgba(0,0,0,0.25)'; }}
onMouseLeave={(e) => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}
>
<img
src={img}
alt={a.title}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', filter: 'brightness(0.55)' }}
loading="lazy"
/>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 10px', background: 'linear-gradient(to top, rgba(5,20,25,0.95), transparent)' }}>
<div style={{ fontSize: 11, color: '#D49A2A', fontWeight: 700, marginBottom: 4 }}>{a.categoryName}</div>
<div style={{ fontSize: 12, color: '#fff', lineHeight: 1.4, fontWeight: 600 }}>{a.title.slice(0, 55)}{a.title.length > 55 ? '…' : ''}</div>
</div>
<div style={{ position: 'absolute', top: 10, right: 10, background: 'rgba(15,107,115,0.85)', borderRadius: 20, padding: '3px 8px', fontSize: 10, color: '#fff', fontWeight: 700 }}>
▶ استوری
</div>
</div>
);
})}
</div>
</div>
);
}
export { SearchPage, CategoryPage, TagPage, GlossaryPage, AuthorsListPage, AuthorProfilePage, SeriesPage, TopicHubPage, NewsletterArchivePage, NewsletterIssuePage, JobsPage, CoursesPage, MembershipPage, MembershipSuccessPage, PrivacyPolicyPage, TermsOfUsePage, AboutPage, ContactPage, ToolsPage, NlpNormalizerPage, RagCalculatorPage, TekRagHubPage, ComparisonPage, DirectoryListPage, CompanyProfilePage, ComparisonsListPage, StoriesListPage };