feat: add SEO configuration and pages for programmatic tools and collections
- Introduced seoPages.ts to manage SEO-related configurations and types for programmatic tools and collection pages. - Created SeoCollectionPage and SeoProgrammaticPage components to render SEO content dynamically based on the new configuration. - Enhanced API service to ensure CSRF token handling for secure requests. - Added generateHowTo utility function for structured data generation. - Updated sitemap generation script to include SEO tool and collection pages. - Configured TypeScript to resolve JSON modules for easier integration of SEO data. ستراتيجية التنفيذ لم أغير أي core logic في أدوات التحويل أو الضغط أو التحرير استخدمت architecture إضافية فوق النظام الحالي بدل استبداله جعلت الـ SEO pages تعتمد على source of truth واحد حتى يسهل التوسع ربطت التوليد مع build حتى لا تبقى sitemap وrobots ثابتة أو منسية دعمت العربية والإنجليزية داخل نفس config الجديد عززت internal linking من: صفحات SEO إلى tool pages صفحات SEO إلى collection pages footer إلى collection pages Suggested tools داخل صفحات الأدوات التحقق
This commit is contained in:
201
frontend/src/pages/SeoCollectionPage.tsx
Normal file
201
frontend/src/pages/SeoCollectionPage.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, FolderKanban, Link2 } from 'lucide-react';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import FAQSection from '@/components/seo/FAQSection';
|
||||
import {
|
||||
getLocalizedText,
|
||||
getLocalizedTextList,
|
||||
getSeoCollectionPage,
|
||||
interpolateTemplate,
|
||||
normalizeSeoLocale,
|
||||
} from '@/config/seoPages';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import { generateBreadcrumbs, generateFAQ, generateWebPage, getSiteOrigin } from '@/utils/seo';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
|
||||
interface SeoCollectionPageProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
en: {
|
||||
toolsHeading: 'Popular tools in this collection',
|
||||
selectionHeading: 'How to choose the right workflow',
|
||||
relatedHeading: 'Related landing pages',
|
||||
openTool: 'Open tool',
|
||||
chooseBullets: [
|
||||
'Pick a conversion workflow when the format itself needs to change.',
|
||||
'Pick a PDF workflow when you need to compress, merge, split, or secure a file.',
|
||||
'Use the shortest path first, then add OCR or cleanup only if the source file needs it.',
|
||||
],
|
||||
breadcrumbLabel: 'Collections',
|
||||
},
|
||||
ar: {
|
||||
toolsHeading: 'أدوات شائعة داخل هذه المجموعة',
|
||||
selectionHeading: 'كيف تختار سير العمل المناسب',
|
||||
relatedHeading: 'صفحات هبوط ذات صلة',
|
||||
openTool: 'افتح الأداة',
|
||||
chooseBullets: [
|
||||
'اختر مسار تحويل عندما تحتاج إلى تغيير الصيغة نفسها.',
|
||||
'اختر مسار PDF عندما تحتاج إلى الضغط أو الدمج أو التقسيم أو الحماية.',
|
||||
'ابدأ بأقصر مسار مباشر، ثم أضف OCR أو التنظيف فقط إذا احتاج الملف المصدر إلى ذلك.',
|
||||
],
|
||||
breadcrumbLabel: 'المجموعات',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function SeoCollectionPage({ slug }: SeoCollectionPageProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = normalizeSeoLocale(i18n.language);
|
||||
const copy = COPY[locale];
|
||||
const page = getSeoCollectionPage(slug);
|
||||
|
||||
if (!page) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
const focusKeyword = getLocalizedText(page.focusKeyword, locale);
|
||||
const tokens = {
|
||||
brand: 'Dociva',
|
||||
focusKeyword,
|
||||
};
|
||||
const title = interpolateTemplate(getLocalizedText(page.titleTemplate, locale), tokens);
|
||||
const description = interpolateTemplate(getLocalizedText(page.descriptionTemplate, locale), tokens);
|
||||
const intro = interpolateTemplate(getLocalizedText(page.introTemplate, locale), tokens);
|
||||
const keywords = [focusKeyword, ...getLocalizedTextList(page.supportingKeywords, locale)].join(', ');
|
||||
const path = `/${page.slug}`;
|
||||
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
const url = `${siteOrigin}${path}`;
|
||||
const faqItems = page.faqTemplates.map((item) => ({
|
||||
question: getLocalizedText(item.question, locale),
|
||||
answer: getLocalizedText(item.answer, locale),
|
||||
}));
|
||||
const relatedCollections = page.relatedCollectionSlugs
|
||||
.map((collectionSlug) => getSeoCollectionPage(collectionSlug))
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry));
|
||||
|
||||
const jsonLd = [
|
||||
generateWebPage({
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
}),
|
||||
generateBreadcrumbs([
|
||||
{ name: t('common.home'), url: siteOrigin },
|
||||
{ name: copy.breadcrumbLabel, url: siteOrigin },
|
||||
{ name: title, url },
|
||||
]),
|
||||
generateFAQ(faqItems),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead title={title} description={description} path={path} keywords={keywords} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-6xl space-y-10">
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 sm:p-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
<FolderKanban className="h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{focusKeyword}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-4xl text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
<p className="mt-4 max-w-4xl leading-8 text-slate-700 dark:text-slate-300">
|
||||
{intro}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{copy.toolsHeading}
|
||||
</h2>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{page.targetToolSlugs.map((toolSlug) => {
|
||||
const tool = getToolSEO(toolSlug);
|
||||
if (!tool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={toolSlug}
|
||||
to={`/tools/${toolSlug}`}
|
||||
className="rounded-2xl border border-slate-200 p-5 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
<p className="text-sm font-medium uppercase tracking-wide text-primary-600 dark:text-primary-400">
|
||||
{tool.category}
|
||||
</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{t(`tools.${tool.i18nKey}.title`)}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
{t(`tools.${tool.i18nKey}.shortDesc`)}
|
||||
</p>
|
||||
<span className="mt-4 inline-flex items-center gap-2 text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
{copy.openTool}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{copy.selectionHeading}
|
||||
</h2>
|
||||
<ul className="mt-5 space-y-3">
|
||||
{copy.chooseBullets.map((item) => (
|
||||
<li key={item} className="rounded-xl bg-slate-50 p-4 text-sm leading-6 text-slate-700 dark:bg-slate-800 dark:text-slate-300">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
{copy.relatedHeading}
|
||||
</h2>
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{relatedCollections.map((collection) => {
|
||||
const collectionTitle = interpolateTemplate(getLocalizedText(collection.titleTemplate, locale), {
|
||||
brand: 'Dociva',
|
||||
focusKeyword: getLocalizedText(collection.focusKeyword, locale),
|
||||
});
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={collection.slug}
|
||||
to={`/${collection.slug}`}
|
||||
className="rounded-2xl border border-slate-200 p-5 transition-colors hover:border-primary-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-primary-600 dark:hover:bg-slate-800"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-primary-600 dark:text-primary-400">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">/{collection.slug}</span>
|
||||
</div>
|
||||
<p className="mt-3 font-semibold text-slate-900 dark:text-white">{collectionTitle}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<FAQSection faqs={faqItems} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user