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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
275
frontend/src/pages/SeoProgrammaticPage.tsx
Normal file
275
frontend/src/pages/SeoProgrammaticPage.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, CheckCircle, FileText, Link2 } from 'lucide-react';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import FAQSection from '@/components/seo/FAQSection';
|
||||
import SuggestedTools from '@/components/seo/SuggestedTools';
|
||||
import {
|
||||
getLocalizedText,
|
||||
getLocalizedTextList,
|
||||
getProgrammaticToolPage,
|
||||
getSeoCollectionPage,
|
||||
interpolateTemplate,
|
||||
normalizeSeoLocale,
|
||||
} from '@/config/seoPages';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import {
|
||||
generateBreadcrumbs,
|
||||
generateFAQ,
|
||||
generateHowTo,
|
||||
generateToolSchema,
|
||||
generateWebPage,
|
||||
getSiteOrigin,
|
||||
} from '@/utils/seo';
|
||||
import NotFoundPage from '@/pages/NotFoundPage';
|
||||
|
||||
interface SeoProgrammaticPageProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
en: {
|
||||
cta: 'Open the tool',
|
||||
introHeading: 'What this page helps you do',
|
||||
workflowHeading: 'Recommended workflow',
|
||||
useCasesHeading: 'When this workflow fits best',
|
||||
relatedHeading: 'Related guides',
|
||||
supportHeading: 'Built for fast bilingual workflows',
|
||||
supportBody:
|
||||
'Dociva supports English and Arabic user flows, which makes these landing pages usable for both local and international search traffic.',
|
||||
stepsName: 'How to use this workflow',
|
||||
breadcrumbLabel: 'Guides',
|
||||
popularTools: 'Popular tools',
|
||||
},
|
||||
ar: {
|
||||
cta: 'افتح الأداة',
|
||||
introHeading: 'ما الذي تساعدك عليه هذه الصفحة',
|
||||
workflowHeading: 'سير العمل المقترح',
|
||||
useCasesHeading: 'متى يكون هذا المسار مناسباً',
|
||||
relatedHeading: 'صفحات ذات صلة',
|
||||
supportHeading: 'مصممة لسير عمل ثنائي اللغة بسرعة',
|
||||
supportBody:
|
||||
'يدعم Dociva سير العمل بالإنجليزية والعربية، مما يجعل صفحات الهبوط هذه قابلة للاستخدام مع الترافيك المحلي والدولي معاً.',
|
||||
stepsName: 'كيفية استخدام هذا المسار',
|
||||
breadcrumbLabel: 'الأدلة',
|
||||
popularTools: 'أدوات شائعة',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default function SeoProgrammaticPage({ slug }: SeoProgrammaticPageProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = normalizeSeoLocale(i18n.language);
|
||||
const copy = COPY[locale];
|
||||
const page = getProgrammaticToolPage(slug);
|
||||
|
||||
if (!page) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
const tool = getToolSEO(page.toolSlug);
|
||||
if (!tool) {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
const toolTitle = t(`tools.${tool.i18nKey}.title`);
|
||||
const toolDescription = t(`tools.${tool.i18nKey}.description`);
|
||||
const steps = t(`seo.${tool.i18nKey}.howToUse`, { returnObjects: true }) as string[];
|
||||
const benefits = t(`seo.${tool.i18nKey}.benefits`, { returnObjects: true }) as string[];
|
||||
const useCases = t(`seo.${tool.i18nKey}.useCases`, { returnObjects: true }) as string[];
|
||||
|
||||
const focusKeyword = getLocalizedText(page.focusKeyword, locale);
|
||||
const keywords = [focusKeyword, ...getLocalizedTextList(page.supportingKeywords, locale)].join(', ');
|
||||
const tokens = {
|
||||
brand: 'Dociva',
|
||||
focusKeyword,
|
||||
};
|
||||
const title = interpolateTemplate(getLocalizedText(page.titleTemplate, locale), tokens);
|
||||
const description = interpolateTemplate(getLocalizedText(page.descriptionTemplate, locale), tokens);
|
||||
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 introBody = `${toolDescription} ${description}`;
|
||||
const workflowBody = `${t(`seo.${tool.i18nKey}.whatItDoes`)} ${t(`tools.${tool.i18nKey}.shortDesc`)}`;
|
||||
const fallbackBenefits = tool.features;
|
||||
const resolvedBenefits = Array.isArray(benefits) && benefits.length > 0 ? benefits : fallbackBenefits;
|
||||
const resolvedUseCases = Array.isArray(useCases) && useCases.length > 0 ? useCases : tool.relatedSlugs.map((relatedSlug) => {
|
||||
const relatedTool = getToolSEO(relatedSlug);
|
||||
return relatedTool ? t(`tools.${relatedTool.i18nKey}.title`) : relatedSlug;
|
||||
});
|
||||
|
||||
const jsonLd = [
|
||||
generateWebPage({
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
}),
|
||||
generateToolSchema({
|
||||
name: toolTitle,
|
||||
description,
|
||||
url,
|
||||
category: tool.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication',
|
||||
}),
|
||||
generateBreadcrumbs([
|
||||
{ name: t('common.home'), url: siteOrigin },
|
||||
{ name: copy.breadcrumbLabel, url: siteOrigin },
|
||||
{ name: title, url },
|
||||
]),
|
||||
generateHowTo({
|
||||
name: copy.stepsName,
|
||||
description,
|
||||
steps: Array.isArray(steps) ? steps : [],
|
||||
url,
|
||||
}),
|
||||
generateFAQ(faqItems),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<SEOHead title={title} description={description} path={path} keywords={keywords} jsonLd={jsonLd} />
|
||||
|
||||
<div className="mx-auto max-w-6xl space-y-12">
|
||||
<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="grid gap-8 lg:grid-cols-[minmax(0,1fr)_320px] lg:items-start">
|
||||
<div>
|
||||
<p className="mb-3 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>
|
||||
<p className="mt-4 max-w-3xl text-lg leading-8 text-slate-600 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
to={`/tools/${page.toolSlug}`}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
{copy.cta}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<span className="inline-flex items-center rounded-xl border border-slate-200 px-4 py-3 text-sm text-slate-600 dark:border-slate-700 dark:text-slate-300">
|
||||
{toolTitle}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-700 dark:bg-slate-800/70">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
<FileText className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{copy.popularTools}</p>
|
||||
<p className="text-base font-semibold text-slate-900 dark:text-white">{toolTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
{toolDescription}
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
{resolvedBenefits.slice(0, 4).map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<CheckCircle className="mt-0.5 h-4 w-4 shrink-0 text-green-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-8 lg:grid-cols-2">
|
||||
<div 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.introHeading}
|
||||
</h2>
|
||||
<p className="mt-4 leading-8 text-slate-700 dark:text-slate-300">
|
||||
{introBody}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div 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.workflowHeading}
|
||||
</h2>
|
||||
<p className="mt-4 leading-8 text-slate-700 dark:text-slate-300">
|
||||
{workflowBody}
|
||||
</p>
|
||||
<ol className="mt-5 list-decimal space-y-2 pl-5 text-slate-700 dark:text-slate-300">
|
||||
{(Array.isArray(steps) ? steps : []).map((step) => (
|
||||
<li key={step}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
</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.useCasesHeading}
|
||||
</h2>
|
||||
<div className="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
{resolvedUseCases.slice(0, 6).map((item) => (
|
||||
<div 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}
|
||||
</div>
|
||||
))}
|
||||
</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.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>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
|
||||
{interpolateTemplate(getLocalizedText(collection.descriptionTemplate, locale), {
|
||||
brand: 'Dociva',
|
||||
focusKeyword: getLocalizedText(collection.focusKeyword, locale),
|
||||
})}
|
||||
</p>
|
||||
</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.supportHeading}
|
||||
</h2>
|
||||
<p className="mt-4 leading-8 text-slate-700 dark:text-slate-300">{copy.supportBody}</p>
|
||||
</section>
|
||||
|
||||
<FAQSection faqs={faqItems} />
|
||||
<SuggestedTools currentSlug={page.toolSlug} limit={4} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user