تم الانتهاء من آخر دفعة تحسينات على المشروع، وتشمل:
تحويل لوحة الإدارة الداخلية من secret header إلى session auth حقيقي مع صلاحيات admin. إضافة دعم إدارة الأدوار من داخل لوحة الإدارة نفسها، مع حماية الحسابات المعتمدة عبر INTERNAL_ADMIN_EMAILS. تحسين بيانات المستخدم في الواجهة والباكند لتشمل role وis_allowlisted_admin. إضافة اختبار frontend مخصص لصفحة /internal/admin بدل الاعتماد فقط على build واختبار routes. تحسين إضافي في الأداء عبر إزالة الاعتماد على pdfjs-dist/pdf.worker في عدّ صفحات PDF واستبداله بمسار أخف باستخدام pdf-lib. تحسين تقسيم الـ chunks في build لتقليل أثر الحزم الكبيرة وفصل أجزاء مثل network, icons, pdf-core, وeditor. التحقق الذي تم: نجاح build للواجهة. نجاح اختبار صفحة الإدارة الداخلية في frontend. نجاح اختبارات auth/admin في backend. نجاح full backend suite مسبقًا مع EXIT:0. ولو تريد نسخة أقصر جدًا، استخدم هذه: آخر التحديثات: تم تحسين نظام الإدارة الداخلية ليعتمد على صلاحيات وجلسات حقيقية بدل secret header، مع إضافة إدارة أدوار من لوحة admin نفسها، وإضافة اختبارات frontend مخصصة للوحة، وتحسين أداء الواجهة عبر إزالة pdf.worker وتحسين تقسيم الـ chunks في build. جميع الاختبارات والتحققات الأساسية المطلوبة نجح
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildLanguageAlternates, getOgLocale } from '@/utils/seo';
|
||||
|
||||
const SITE_NAME = 'SaaS-PDF';
|
||||
|
||||
@@ -23,9 +25,12 @@ interface SEOHeadProps {
|
||||
* - Optional JSON-LD structured data
|
||||
*/
|
||||
export default function SEOHead({ title, description, path, type = 'website', jsonLd }: SEOHeadProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const fullTitle = `${title} — ${SITE_NAME}`;
|
||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
|
||||
const schemas = jsonLd ? (Array.isArray(jsonLd) ? jsonLd : [jsonLd]) : [];
|
||||
|
||||
@@ -34,6 +39,15 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
{languageAlternates.map((alternate) => (
|
||||
<link
|
||||
key={alternate.hrefLang}
|
||||
rel="alternate"
|
||||
hrefLang={alternate.hrefLang}
|
||||
href={alternate.href}
|
||||
/>
|
||||
))}
|
||||
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
|
||||
|
||||
{/* OpenGraph */}
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
@@ -41,9 +55,12 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="ar_SA" />
|
||||
<meta property="og:locale:alternate" content="fr_FR" />
|
||||
<meta property="og:locale" content={currentOgLocale} />
|
||||
{languageAlternates
|
||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
||||
.map((alternate) => (
|
||||
<meta key={alternate.ogLocale} property="og:locale:alternate" content={alternate.ogLocale} />
|
||||
))}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
|
||||
74
frontend/src/components/seo/SuggestedTools.tsx
Normal file
74
frontend/src/components/seo/SuggestedTools.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
|
||||
interface SuggestedToolsProps {
|
||||
currentSlug: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
PDF: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400',
|
||||
Image: 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400',
|
||||
AI: 'bg-violet-50 text-violet-700 dark:bg-violet-900/20 dark:text-violet-400',
|
||||
Convert: 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400',
|
||||
Utility: 'bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400',
|
||||
};
|
||||
|
||||
export default function SuggestedTools({ currentSlug, limit = 3 }: SuggestedToolsProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentTool = getToolSEO(currentSlug);
|
||||
|
||||
if (!currentTool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relatedTools = currentTool.relatedSlugs
|
||||
.map((slug) => getToolSEO(slug))
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
|
||||
if (relatedTools.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-6 rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900/60">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-white">
|
||||
{t('home.suggestedTools')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
{t('home.suggestedToolsDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{relatedTools.map((tool) => (
|
||||
<Link
|
||||
key={tool!.slug}
|
||||
to={`/tools/${tool!.slug}`}
|
||||
className="group rounded-xl border border-slate-200 bg-slate-50 p-4 transition-colors hover:border-primary-300 hover:bg-white dark:border-slate-700 dark:bg-slate-800 dark:hover:border-primary-600"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-semibold text-slate-800 group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
|
||||
{t(`tools.${tool!.i18nKey}.title`)}
|
||||
</h4>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${CATEGORY_COLORS[tool!.category] || ''}`}>
|
||||
{tool!.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-slate-600 dark:text-slate-400">
|
||||
{t(`tools.${tool!.i18nKey}.shortDesc`)}
|
||||
</p>
|
||||
<span className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400">
|
||||
{t('common.tryOtherTools')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,12 @@ import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import { generateToolSchema, generateBreadcrumbs, generateFAQ } from '@/utils/seo';
|
||||
import { buildLanguageAlternates, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
|
||||
import FAQSection from './FAQSection';
|
||||
import RelatedTools from './RelatedTools';
|
||||
import ToolRating from '@/components/shared/ToolRating';
|
||||
import SharePanel from '@/components/shared/SharePanel';
|
||||
import ToolWorkflowPanel from '@/components/shared/ToolWorkflowPanel';
|
||||
import { useToolRating } from '@/hooks/useToolRating';
|
||||
import { dispatchRatingPrompt } from '@/utils/ratingPrompt';
|
||||
|
||||
@@ -27,7 +28,7 @@ interface ToolLandingPageProps {
|
||||
* feature bullets, and proper meta tags around any tool component.
|
||||
*/
|
||||
export default function ToolLandingPage({ slug, children }: ToolLandingPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const seo = getToolSEO(slug);
|
||||
const ratingData = useToolRating(slug);
|
||||
|
||||
@@ -37,7 +38,10 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
const toolTitle = t(`tools.${seo.i18nKey}.title`);
|
||||
const toolDesc = t(`tools.${seo.i18nKey}.description`);
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const canonicalUrl = `${origin}/tools/${slug}`;
|
||||
const path = `/tools/${slug}`;
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
|
||||
const toolSchema = generateToolSchema({
|
||||
name: toolTitle,
|
||||
@@ -63,12 +67,27 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
<meta name="description" content={seo.metaDescription} />
|
||||
<meta name="keywords" content={seo.keywords} />
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
{languageAlternates.map((alternate) => (
|
||||
<link
|
||||
key={alternate.hrefLang}
|
||||
rel="alternate"
|
||||
hrefLang={alternate.hrefLang}
|
||||
href={alternate.href}
|
||||
/>
|
||||
))}
|
||||
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
||||
<meta property="og:description" content={seo.metaDescription} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content={currentOgLocale} />
|
||||
{languageAlternates
|
||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
||||
.map((alternate) => (
|
||||
<meta key={alternate.ogLocale} property="og:locale:alternate" content={alternate.ogLocale} />
|
||||
))}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
@@ -109,6 +128,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
|
||||
{/* SEO Content Below Tool */}
|
||||
<div className="mx-auto mt-16 max-w-3xl">
|
||||
<ToolWorkflowPanel />
|
||||
|
||||
{/* What this tool does */}
|
||||
<section className="mb-12">
|
||||
<h2 className="mb-4 text-xl font-bold text-slate-900 dark:text-white">
|
||||
|
||||
Reference in New Issue
Block a user