- Bloc GEO/IA (resume IA, FAQ, points cles, entites) + rendu public "En bref"/"A retenir"/FAQ + JSON-LD Article & FAQPage. - Corbeille: soft-delete (deleted_at), filtres Tous/Publies/Brouillons/ Planifies/Corbeille, restauration + suppression definitive. - Duplication d'un article (nouveau brouillon, slug unique). - Drag & drop de l'image de couverture. - Recadrage auto de la couverture en 16:9 (sharp) via endpoint dedie /admin/blog/cover-images. - Migration AddGeoAndTrashToBlogPosts (ai_summary, faq, key_takeaways, ai_entities, deleted_at). Ajout dependance sharp. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
153 lines
3.9 KiB
TypeScript
153 lines
3.9 KiB
TypeScript
import type { Metadata } from 'next';
|
|
import BlogPostContent from './BlogPostContent';
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
|
|
|
interface BlogFaqItem {
|
|
question: string;
|
|
answer: string;
|
|
}
|
|
|
|
interface BlogPostMeta {
|
|
title: string;
|
|
slug: string;
|
|
excerpt: string;
|
|
metaTitle?: string;
|
|
metaDescription?: string;
|
|
primaryKeyword?: string;
|
|
secondaryKeywords?: string[];
|
|
coverImageUrl?: string;
|
|
authorName: string;
|
|
publishedAt?: string;
|
|
updatedAt: string;
|
|
aiSummary?: string | null;
|
|
faq?: BlogFaqItem[];
|
|
keyTakeaways?: string[];
|
|
}
|
|
|
|
async function fetchPostMeta(slug: string): Promise<BlogPostMeta | null> {
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, {
|
|
cache: 'no-store',
|
|
});
|
|
if (!res.ok) return null;
|
|
return (await res.json()) as BlogPostMeta;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function buildJsonLd(post: BlogPostMeta, slug: string) {
|
|
const coverImage = post.coverImageUrl
|
|
? post.coverImageUrl.startsWith('http')
|
|
? post.coverImageUrl
|
|
: `${API_BASE_URL}${post.coverImageUrl}`
|
|
: undefined;
|
|
|
|
const article = {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'Article',
|
|
headline: post.metaTitle || post.title,
|
|
description: post.metaDescription || post.aiSummary || post.excerpt,
|
|
image: coverImage ? [coverImage] : undefined,
|
|
datePublished: post.publishedAt,
|
|
dateModified: post.updatedAt,
|
|
author: { '@type': 'Person', name: post.authorName },
|
|
publisher: { '@type': 'Organization', name: 'Xpeditis' },
|
|
mainEntityOfPage: { '@type': 'WebPage', '@id': `https://xpeditis.com/blog/${slug}` },
|
|
keywords: [post.primaryKeyword, ...(post.secondaryKeywords ?? [])]
|
|
.filter(Boolean)
|
|
.join(', '),
|
|
};
|
|
|
|
const faqPage =
|
|
post.faq && post.faq.length > 0
|
|
? {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'FAQPage',
|
|
mainEntity: post.faq.map(item => ({
|
|
'@type': 'Question',
|
|
name: item.question,
|
|
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
|
})),
|
|
}
|
|
: null;
|
|
|
|
return { article, faqPage };
|
|
}
|
|
|
|
export async function generateMetadata({
|
|
params,
|
|
}: {
|
|
params: { slug: string; locale: string };
|
|
}): Promise<Metadata> {
|
|
const post = await fetchPostMeta(params.slug);
|
|
|
|
if (!post) {
|
|
return { title: 'Blog — Xpeditis' };
|
|
}
|
|
|
|
const title = post.metaTitle || post.title;
|
|
const description = post.metaDescription || post.excerpt;
|
|
const keywords = [post.primaryKeyword, ...(post.secondaryKeywords ?? [])]
|
|
.filter(Boolean)
|
|
.join(', ');
|
|
const coverImage = post.coverImageUrl
|
|
? post.coverImageUrl.startsWith('http')
|
|
? post.coverImageUrl
|
|
: `${API_BASE_URL}${post.coverImageUrl}`
|
|
: undefined;
|
|
|
|
return {
|
|
title,
|
|
description,
|
|
keywords: keywords || undefined,
|
|
openGraph: {
|
|
title,
|
|
description,
|
|
type: 'article',
|
|
publishedTime: post.publishedAt,
|
|
authors: [post.authorName],
|
|
images: coverImage ? [{ url: coverImage, alt: post.title }] : [],
|
|
},
|
|
twitter: {
|
|
card: 'summary_large_image',
|
|
title,
|
|
description,
|
|
images: coverImage ? [coverImage] : [],
|
|
},
|
|
alternates: {
|
|
canonical: `/blog/${params.slug}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
export default async function BlogPostPage({
|
|
params,
|
|
}: {
|
|
params: { slug: string; locale: string };
|
|
}) {
|
|
const post = await fetchPostMeta(params.slug);
|
|
const jsonLd = post ? buildJsonLd(post, params.slug) : null;
|
|
|
|
return (
|
|
<>
|
|
{jsonLd && (
|
|
<>
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.article) }}
|
|
/>
|
|
{jsonLd.faqPage && (
|
|
<script
|
|
type="application/ld+json"
|
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.faqPage) }}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
<BlogPostContent slug={params.slug} />
|
|
</>
|
|
);
|
|
}
|