feat: Enhance task access control and session management
- Implemented API and web task access assertions in the task status polling endpoint. - Added functions to remember and check task access in user sessions. - Updated task status tests to validate access control based on session data. - Enhanced download route tests to ensure proper access checks. - Improved SEO metadata handling with dynamic social preview images. - Updated sitemap generation to include blog posts and new tools. - Added a social preview SVG for better sharing on social media platforms.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildLanguageAlternates, getOgLocale } from '@/utils/seo';
|
||||
import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo';
|
||||
|
||||
const SITE_NAME = 'Dociva';
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
const { i18n } = useTranslation();
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const socialImageUrl = buildSocialImageUrl(origin);
|
||||
const fullTitle = `${title} — ${SITE_NAME}`;
|
||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
@@ -55,6 +56,8 @@ 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:image" content={socialImageUrl} />
|
||||
<meta property="og:image:alt" content={`${fullTitle} social preview`} />
|
||||
<meta property="og:locale" content={currentOgLocale} />
|
||||
{languageAlternates
|
||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
||||
@@ -63,9 +66,11 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
))}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
<meta name="twitter:image:alt" content={`${fullTitle} social preview`} />
|
||||
|
||||
{/* JSON-LD Structured Data */}
|
||||
{schemas.map((schema, i) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import { buildLanguageAlternates, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
|
||||
import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
|
||||
import FAQSection from './FAQSection';
|
||||
import RelatedTools from './RelatedTools';
|
||||
import ToolRating from '@/components/shared/ToolRating';
|
||||
@@ -40,6 +40,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const path = `/tools/${slug}`;
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const socialImageUrl = buildSocialImageUrl(origin);
|
||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
|
||||
@@ -82,6 +83,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
<meta property="og:description" content={seo.metaDescription} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={socialImageUrl} />
|
||||
<meta property="og:image:alt" content={`${toolTitle} social preview`} />
|
||||
<meta property="og:locale" content={currentOgLocale} />
|
||||
{languageAlternates
|
||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
||||
@@ -90,9 +93,11 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
))}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
||||
<meta name="twitter:description" content={seo.metaDescription} />
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
<meta name="twitter:image:alt" content={`${toolTitle} social preview`} />
|
||||
|
||||
{/* Structured Data */}
|
||||
<script type="application/ld+json">{JSON.stringify(toolSchema)}</script>
|
||||
|
||||
@@ -37,17 +37,28 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasReliableUsageStats = stats.total_files_processed >= 25;
|
||||
const hasReliableRating = stats.rating_count >= 3;
|
||||
|
||||
const topTools = stats.top_tools.slice(0, 3).map((tool) => {
|
||||
const seo = getToolSEO(tool.tool);
|
||||
return seo ? t(`tools.${seo.i18nKey}.title`) : tool.tool;
|
||||
});
|
||||
|
||||
const cards = [
|
||||
{ label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() },
|
||||
{ label: t('socialProof.successRate'), value: `${stats.success_rate}%` },
|
||||
{ label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() },
|
||||
{ label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` },
|
||||
];
|
||||
hasReliableUsageStats
|
||||
? { label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() }
|
||||
: null,
|
||||
hasReliableUsageStats
|
||||
? { label: t('socialProof.successRate'), value: `${stats.success_rate}%` }
|
||||
: null,
|
||||
hasReliableUsageStats
|
||||
? { label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() }
|
||||
: null,
|
||||
hasReliableRating
|
||||
? { label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` }
|
||||
: null,
|
||||
].filter((card): card is { label: string; value: string } => Boolean(card));
|
||||
|
||||
return (
|
||||
<section className={`rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
|
||||
@@ -73,20 +84,31 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:min-w-[420px]">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-2xl bg-slate-50 p-4 dark:bg-slate-800/70">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{card.label}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{cards.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:min-w-[420px]">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-2xl bg-slate-50 p-4 dark:bg-slate-800/70">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-slate-400 dark:text-slate-500">{card.label}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-slate-50 p-5 text-sm leading-7 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300 lg:max-w-md">
|
||||
{t(
|
||||
'socialProof.pendingSummary',
|
||||
'Public activity metrics appear here after we collect enough completed jobs and verified ratings.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 border-t border-slate-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-700">
|
||||
<p className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
{t('socialProof.basedOnRatings', { count: stats.rating_count })}
|
||||
{hasReliableRating
|
||||
? t('socialProof.basedOnRatings', { count: stats.rating_count })
|
||||
: t('socialProof.pendingRatings', 'Ratings summary will unlock after enough verified feedback.')}
|
||||
</p>
|
||||
<Link to="/developers" className="text-sm font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
{t('socialProof.viewDevelopers')}
|
||||
|
||||
@@ -22,15 +22,14 @@ const ENDPOINT_GROUPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const CURL_UPLOAD = `curl -X POST https://your-domain.example/api/v1/convert/pdf-to-word \\
|
||||
-H "X-API-Key: spdf_your_api_key" \\
|
||||
-F "file=@./sample.pdf"`;
|
||||
|
||||
const CURL_POLL = `curl https://your-domain.example/api/v1/tasks/<task_id>/status \\
|
||||
-H "X-API-Key: spdf_your_api_key"`;
|
||||
|
||||
export default function DevelopersPage() {
|
||||
const { t } = useTranslation();
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://dociva.io';
|
||||
const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\
|
||||
-H "X-API-Key: spdf_your_api_key" \\
|
||||
-F "file=@./sample.pdf"`;
|
||||
const curlPoll = `curl ${origin}/api/tasks/<task_id>/status \\
|
||||
-H "X-API-Key: spdf_your_api_key"`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -91,12 +90,12 @@ export default function DevelopersPage() {
|
||||
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.authExampleTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.authExampleSubtitle')}</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{CURL_UPLOAD}</code></pre>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{curlUpload}</code></pre>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.pollExampleTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.pollExampleSubtitle')}</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{CURL_POLL}</code></pre>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{curlPoll}</code></pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface LanguageAlternate {
|
||||
ogLocale: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
|
||||
|
||||
const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = {
|
||||
en: { hrefLang: 'en', ogLocale: 'en_US' },
|
||||
ar: { hrefLang: 'ar', ogLocale: 'ar_SA' },
|
||||
@@ -42,6 +44,10 @@ export function buildLanguageAlternates(origin: string, path: string): LanguageA
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSocialImageUrl(origin: string): string {
|
||||
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WebApplication JSON-LD structured data for a tool page.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user