xpeditis2.0/apps/frontend/app/[locale]/blog/[slug]/page.tsx
David 8877265526 feat(blog): Phase 3 GEO/IA, corbeille, duplication, drag&drop, recadrage
- 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>
2026-07-01 13:44:35 +02:00

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} />
</>
);
}