From 1906161d3707e74a3ebc5acf8cebeaf17de3cb77 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 11 Jun 2026 19:18:56 +0200 Subject: [PATCH] fix blog and animation connexion --- .../controllers/admin.controller.ts | 4 + .../controllers/blog.controller.ts | 4 + .../src/application/dto/blog-post.dto.ts | 63 ++++ .../src/application/services/blog.service.ts | 25 +- .../src/domain/entities/blog-post.entity.ts | 49 ++- .../domain/ports/out/blog-post.repository.ts | 2 + .../typeorm/entities/blog-post.orm-entity.ts | 12 + .../1747000000000-AddSeoFieldsToBlogPosts.ts | 41 ++ .../typeorm-blog-post.repository.ts | 20 +- .../[locale]/blog/[slug]/BlogPostContent.tsx | 301 +++++++++++++++ .../app/[locale]/blog/[slug]/page.tsx | 293 ++++----------- .../[locale]/dashboard/admin/blog/page.tsx | 354 +++++++++++++++--- apps/frontend/app/[locale]/login/page.tsx | 86 ++++- apps/frontend/app/[locale]/register/page.tsx | 86 ++++- apps/frontend/messages/en.json | 6 +- apps/frontend/messages/fr.json | 6 +- apps/frontend/middleware.ts | 5 + apps/frontend/src/lib/api/admin.ts | 12 +- apps/frontend/src/lib/api/blog.ts | 6 +- 19 files changed, 1084 insertions(+), 291 deletions(-) create mode 100644 apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts create mode 100644 apps/frontend/app/[locale]/blog/[slug]/BlogPostContent.tsx diff --git a/apps/backend/src/application/controllers/admin.controller.ts b/apps/backend/src/application/controllers/admin.controller.ts index b9ecda5..5c8393c 100644 --- a/apps/backend/src/application/controllers/admin.controller.ts +++ b/apps/backend/src/application/controllers/admin.controller.ts @@ -1068,6 +1068,10 @@ export class AdminController { status: post.status, isFeatured: post.isFeatured, publishedAt: post.publishedAt, + metaTitle: post.metaTitle, + metaDescription: post.metaDescription, + primaryKeyword: post.primaryKeyword, + secondaryKeywords: post.secondaryKeywords, createdAt: post.createdAt, updatedAt: post.updatedAt, }; diff --git a/apps/backend/src/application/controllers/blog.controller.ts b/apps/backend/src/application/controllers/blog.controller.ts index 39845a9..3a36562 100644 --- a/apps/backend/src/application/controllers/blog.controller.ts +++ b/apps/backend/src/application/controllers/blog.controller.ts @@ -124,6 +124,10 @@ export class BlogController { status: post.status, isFeatured: post.isFeatured, publishedAt: post.publishedAt, + metaTitle: post.metaTitle, + metaDescription: post.metaDescription, + primaryKeyword: post.primaryKeyword, + secondaryKeywords: post.secondaryKeywords, createdAt: post.createdAt, updatedAt: post.updatedAt, }; diff --git a/apps/backend/src/application/dto/blog-post.dto.ts b/apps/backend/src/application/dto/blog-post.dto.ts index 1dc280a..129db6c 100644 --- a/apps/backend/src/application/dto/blog-post.dto.ts +++ b/apps/backend/src/application/dto/blog-post.dto.ts @@ -6,6 +6,7 @@ import { IsArray, IsBoolean, IsEnum, + IsDateString, MaxLength, MinLength, Matches, @@ -62,6 +63,35 @@ export class CreateBlogPostDto { @IsNotEmpty() @MaxLength(255) authorName: string; + + @ApiPropertyOptional({ description: 'ISO date string for scheduled publication' }) + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @ApiPropertyOptional({ description: 'SEO meta title (50-60 chars recommended)' }) + @IsOptional() + @IsString() + @MaxLength(255) + metaTitle?: string; + + @ApiPropertyOptional({ description: 'SEO meta description (150-160 chars recommended)' }) + @IsOptional() + @IsString() + @MaxLength(500) + metaDescription?: string; + + @ApiPropertyOptional({ description: 'Primary SEO keyword' }) + @IsOptional() + @IsString() + @MaxLength(255) + primaryKeyword?: string; + + @ApiPropertyOptional({ type: [String], description: 'Secondary SEO keywords' }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + secondaryKeywords?: string[]; } export class UpdateBlogPostDto { @@ -123,6 +153,35 @@ export class UpdateBlogPostDto { @IsOptional() @IsBoolean() isFeatured?: boolean; + + @ApiPropertyOptional({ description: 'ISO date string for scheduled publication' }) + @IsOptional() + @IsDateString() + scheduledAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + metaTitle?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + metaDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + primaryKeyword?: string; + + @ApiPropertyOptional({ type: [String] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + secondaryKeywords?: string[]; } export class BlogPostResponseDto { @@ -138,6 +197,10 @@ export class BlogPostResponseDto { @ApiProperty({ enum: BlogPostStatus }) status: BlogPostStatus; @ApiProperty() isFeatured: boolean; @ApiPropertyOptional() publishedAt?: Date; + @ApiPropertyOptional() metaTitle?: string; + @ApiPropertyOptional() metaDescription?: string; + @ApiPropertyOptional() primaryKeyword?: string; + @ApiProperty({ type: [String] }) secondaryKeywords: string[]; @ApiProperty() createdAt: Date; @ApiProperty() updatedAt: Date; } diff --git a/apps/backend/src/application/services/blog.service.ts b/apps/backend/src/application/services/blog.service.ts index 7cd3a52..5e581d8 100644 --- a/apps/backend/src/application/services/blog.service.ts +++ b/apps/backend/src/application/services/blog.service.ts @@ -44,7 +44,7 @@ export class BlogService implements OnApplicationBootstrap { throw new ConflictException(`Slug "${dto.slug}" is already in use`); } - const post = BlogPost.create({ + let post = BlogPost.create({ id: uuidv4(), title: dto.title, slug: dto.slug, @@ -54,8 +54,17 @@ export class BlogService implements OnApplicationBootstrap { category: dto.category, tags: dto.tags ?? [], authorName: dto.authorName, + metaTitle: dto.metaTitle, + metaDescription: dto.metaDescription, + primaryKeyword: dto.primaryKeyword, + secondaryKeywords: dto.secondaryKeywords ?? [], }); + if (dto.scheduledAt) { + const schedDate = new Date(dto.scheduledAt); + post = schedDate > new Date() ? post.schedule(schedDate) : post.publish(); + } + return this.blogPostRepository.save(post); } @@ -79,12 +88,21 @@ export class BlogService implements OnApplicationBootstrap { tags: dto.tags, authorName: dto.authorName, isFeatured: dto.isFeatured, + metaTitle: dto.metaTitle, + metaDescription: dto.metaDescription, + primaryKeyword: dto.primaryKeyword, + secondaryKeywords: dto.secondaryKeywords, }); - if (dto.status !== undefined && dto.status !== post.status) { + if (dto.scheduledAt) { + const schedDate = new Date(dto.scheduledAt); + updated = schedDate > new Date() ? updated.schedule(schedDate) : updated.publish(); + } else if (dto.status !== undefined && dto.status !== post.status) { if (dto.status === BlogPostStatus.PUBLISHED) updated = updated.publish(); else if (dto.status === BlogPostStatus.ARCHIVED) updated = updated.archive(); else if (dto.status === BlogPostStatus.DRAFT) updated = updated.unpublish(); + else if (dto.status === BlogPostStatus.SCHEDULED && post.publishedAt) + updated = updated.schedule(post.publishedAt); } return this.blogPostRepository.save(updated); @@ -101,7 +119,7 @@ export class BlogService implements OnApplicationBootstrap { async getPublishedPostBySlug(slug: string): Promise { const post = await this.blogPostRepository.findBySlug(slug); - if (!post || !post.isPublished()) { + if (!post || !post.isVisibleToPublic()) { throw new NotFoundException('Article not found'); } return post; @@ -113,6 +131,7 @@ export class BlogService implements OnApplicationBootstrap { const publishedFilters: BlogPostFilters = { ...filters, status: BlogPostStatus.PUBLISHED, + includeScheduled: true, }; const [posts, total] = await Promise.all([ this.blogPostRepository.findByFilters(publishedFilters), diff --git a/apps/backend/src/domain/entities/blog-post.entity.ts b/apps/backend/src/domain/entities/blog-post.entity.ts index c16086f..74a0f56 100644 --- a/apps/backend/src/domain/entities/blog-post.entity.ts +++ b/apps/backend/src/domain/entities/blog-post.entity.ts @@ -1,5 +1,6 @@ export enum BlogPostStatus { DRAFT = 'draft', + SCHEDULED = 'scheduled', PUBLISHED = 'published', ARCHIVED = 'archived', } @@ -19,6 +20,10 @@ interface BlogPostProps { status: BlogPostStatus; isFeatured: boolean; publishedAt?: Date; + metaTitle?: string; + metaDescription?: string; + primaryKeyword?: string; + secondaryKeywords: string[]; createdAt: Date; updatedAt: Date; } @@ -32,6 +37,7 @@ export class BlogPost { const now = new Date(); return new BlogPost({ ...props, + secondaryKeywords: props.secondaryKeywords ?? [], status: BlogPostStatus.DRAFT, isFeatured: false, createdAt: now, @@ -79,6 +85,18 @@ export class BlogPost { get publishedAt(): Date | undefined { return this.props.publishedAt; } + get metaTitle(): string | undefined { + return this.props.metaTitle; + } + get metaDescription(): string | undefined { + return this.props.metaDescription; + } + get primaryKeyword(): string | undefined { + return this.props.primaryKeyword; + } + get secondaryKeywords(): string[] { + return this.props.secondaryKeywords; + } get createdAt(): Date { return this.props.createdAt; } @@ -99,6 +117,10 @@ export class BlogPost { | 'tags' | 'authorName' | 'isFeatured' + | 'metaTitle' + | 'metaDescription' + | 'primaryKeyword' + | 'secondaryKeywords' > > ): BlogPost { @@ -114,18 +136,43 @@ export class BlogPost { }); } + schedule(date: Date): BlogPost { + return new BlogPost({ + ...this.props, + status: BlogPostStatus.SCHEDULED, + publishedAt: date, + updatedAt: new Date(), + }); + } + archive(): BlogPost { return new BlogPost({ ...this.props, status: BlogPostStatus.ARCHIVED, updatedAt: new Date() }); } unpublish(): BlogPost { - return new BlogPost({ ...this.props, status: BlogPostStatus.DRAFT, updatedAt: new Date() }); + return new BlogPost({ + ...this.props, + status: BlogPostStatus.DRAFT, + publishedAt: undefined, + updatedAt: new Date(), + }); } isPublished(): boolean { return this.props.status === BlogPostStatus.PUBLISHED; } + isVisibleToPublic(): boolean { + if (this.props.status === BlogPostStatus.PUBLISHED) return true; + if ( + this.props.status === BlogPostStatus.SCHEDULED && + this.props.publishedAt && + this.props.publishedAt <= new Date() + ) + return true; + return false; + } + toObject(): BlogPostProps { return { ...this.props }; } diff --git a/apps/backend/src/domain/ports/out/blog-post.repository.ts b/apps/backend/src/domain/ports/out/blog-post.repository.ts index 07993a3..8e8e039 100644 --- a/apps/backend/src/domain/ports/out/blog-post.repository.ts +++ b/apps/backend/src/domain/ports/out/blog-post.repository.ts @@ -9,6 +9,8 @@ export interface BlogPostFilters { isFeatured?: boolean; limit?: number; offset?: number; + /** When true, also include SCHEDULED posts whose publishedAt <= now */ + includeScheduled?: boolean; } export interface BlogPostRepository { diff --git a/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts index b86114c..36cb24d 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/entities/blog-post.orm-entity.ts @@ -40,6 +40,18 @@ export class BlogPostOrmEntity { @Column('timestamp', { nullable: true }) published_at?: Date; + @Column('varchar', { length: 255, nullable: true }) + meta_title?: string; + + @Column('varchar', { length: 500, nullable: true }) + meta_description?: string; + + @Column('varchar', { length: 255, nullable: true }) + primary_keyword?: string; + + @Column('jsonb', { default: [] }) + secondary_keywords: string[]; + @CreateDateColumn() created_at: Date; diff --git a/apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts new file mode 100644 index 0000000..fa11348 --- /dev/null +++ b/apps/backend/src/infrastructure/persistence/typeorm/migrations/1747000000000-AddSeoFieldsToBlogPosts.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddSeoFieldsToBlogPosts1747000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumns('blog_posts', [ + new TableColumn({ + name: 'meta_title', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'meta_description', + type: 'varchar', + length: '500', + isNullable: true, + }), + new TableColumn({ + name: 'primary_keyword', + type: 'varchar', + length: '255', + isNullable: true, + }), + new TableColumn({ + name: 'secondary_keywords', + type: 'jsonb', + isNullable: false, + default: "'[]'", + }), + ]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumns('blog_posts', [ + 'meta_title', + 'meta_description', + 'primary_keyword', + 'secondary_keywords', + ]); + } +} diff --git a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts index 4503074..7cac371 100644 --- a/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts +++ b/apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-blog-post.repository.ts @@ -31,7 +31,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { async findByFilters(filters: BlogPostFilters): Promise { const query = this.ormRepository.createQueryBuilder('post'); - if (filters.status) { + if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) { + query.andWhere( + "(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))" + ); + } else if (filters.status) { query.andWhere('post.status = :status', { status: filters.status }); } @@ -61,7 +65,11 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { async count(filters: BlogPostFilters): Promise { const query = this.ormRepository.createQueryBuilder('post'); - if (filters.status) { + if (filters.includeScheduled && filters.status === BlogPostStatus.PUBLISHED) { + query.andWhere( + "(post.status = 'published' OR (post.status = 'scheduled' AND post.published_at <= CURRENT_TIMESTAMP))" + ); + } else if (filters.status) { query.andWhere('post.status = :status', { status: filters.status }); } if (filters.category) { @@ -105,6 +113,10 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { status: orm.status as BlogPostStatus, isFeatured: orm.is_featured, publishedAt: orm.published_at, + metaTitle: orm.meta_title, + metaDescription: orm.meta_description, + primaryKeyword: orm.primary_keyword, + secondaryKeywords: orm.secondary_keywords ?? [], createdAt: orm.created_at, updatedAt: orm.updated_at, }); @@ -124,6 +136,10 @@ export class TypeOrmBlogPostRepository implements BlogPostRepository { orm.status = post.status; orm.is_featured = post.isFeatured; orm.published_at = post.publishedAt; + orm.meta_title = post.metaTitle; + orm.meta_description = post.metaDescription; + orm.primary_keyword = post.primaryKeyword; + orm.secondary_keywords = post.secondaryKeywords; orm.created_at = post.createdAt; orm.updated_at = post.updatedAt; return orm; diff --git a/apps/frontend/app/[locale]/blog/[slug]/BlogPostContent.tsx b/apps/frontend/app/[locale]/blog/[slug]/BlogPostContent.tsx new file mode 100644 index 0000000..0ed58fa --- /dev/null +++ b/apps/frontend/app/[locale]/blog/[slug]/BlogPostContent.tsx @@ -0,0 +1,301 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { motion } from 'framer-motion'; +import { ArrowLeft, Calendar, User, Tag, Clock, Share2, BookOpen, Anchor } from 'lucide-react'; +import { Link } from '@/i18n/navigation'; +import { LandingHeader, LandingFooter } from '@/components/layout'; +import { getBlogPost, type BlogPost } from '@/lib/api/blog'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; + +function resolveUrl(url: string | null | undefined): string | undefined { + if (!url) return undefined; + if (url.startsWith('http')) return url; + return `${API_BASE_URL}${url}`; +} + +function formatDate(dateStr: string): string { + return new Date(dateStr).toLocaleDateString('fr-FR', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +function estimateReadingTime(content: string): number { + const text = content.replace(/<[^>]*>/g, ''); + const words = text.trim().split(/\s+/).length; + return Math.max(1, Math.ceil(words / 200)); +} + +const CATEGORY_LABELS: Record = { + industry: 'Industrie', + technology: 'Technologie', + guides: 'Guides', + news: 'Actualités', +}; + +function LoadingSkeleton() { + return ( +
+ +
+
+
+
+
+
+
+ {[...Array(6)].map((_, i) => ( +
+ ))} +
+
+
+ +
+ ); +} + +function NotFoundView() { + return ( +
+ +
+ +

Article introuvable

+

+ Cet article n'existe pas ou n'est pas encore publié. +

+ + + + Retour au blog + + +
+ +
+ ); +} + +export default function BlogPostContent({ slug }: { slug: string }) { + const [post, setPost] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + + useEffect(() => { + if (!slug) return; + let cancelled = false; + + getBlogPost(slug) + .then(p => { + if (!cancelled) setPost(p); + }) + .catch(() => { + if (!cancelled) setNotFound(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [slug]); + + if (loading) return ; + if (notFound || !post) return ; + + const readingTime = estimateReadingTime(post.content); + const processedContent = post.content.replace( + /src="(\/api\/v1\/blog\/images\/[^"]+)"/g, + `src="${API_BASE_URL}$1"` + ); + + const handleShare = () => { + if (navigator.share) { + navigator.share({ title: post.title, url: window.location.href }); + } else { + navigator.clipboard.writeText(window.location.href); + } + }; + + return ( +
+ + + {/* Hero */} +
+
+
+
+
+ +
+ + + + + Retour au blog + + + +
+ + {CATEGORY_LABELS[post.category] ?? post.category} + + {post.isFeatured && ( + + À la une + + )} +
+ +

+ {post.title} +

+ +

{post.excerpt}

+ +
+
+ + {post.authorName} +
+ {post.publishedAt && ( +
+ + {formatDate(post.publishedAt)} +
+ )} +
+ + {readingTime} min de lecture +
+ {post.tags.length > 0 && ( +
+ + {post.tags.join(', ')} +
+ )} +
+
+
+ +
+ + + +
+
+ + {/* Cover Image */} + {post.coverImageUrl && ( +
+ + {post.title} + +
+ )} + + {/* Content + Sidebar */} +
+
+
+ {/* Article body */} + + + {/* Floating share button (desktop) */} +
+ +
+
+ + {/* Tags */} + {post.tags.length > 0 && ( +
+ {post.tags.map(tag => ( + + #{tag} + + ))} +
+ )} + + {/* CTA */} +
+ +

+ Prêt à tester Xpeditis ? +

+

+ Créez votre compte en 2 minutes et lancez votre premier chiffrage maritime instantané. + C'est gratuit et sans engagement. +

+ + Créer mon compte gratuitement + +
+ + {/* Back + Share */} +
+ + + + Retour au blog + + + +
+
+
+ + +
+ ); +} diff --git a/apps/frontend/app/[locale]/blog/[slug]/page.tsx b/apps/frontend/app/[locale]/blog/[slug]/page.tsx index 240c0b4..445a8a9 100644 --- a/apps/frontend/app/[locale]/blog/[slug]/page.tsx +++ b/apps/frontend/app/[locale]/blog/[slug]/page.tsx @@ -1,230 +1,81 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { useParams } from 'next/navigation'; -import { Link } from '@/i18n/navigation'; -import { motion } from 'framer-motion'; -import { ArrowLeft, Calendar, User, Tag, Anchor } from 'lucide-react'; -import { LandingHeader, LandingFooter } from '@/components/layout'; -import { getBlogPost, type BlogPost } from '@/lib/api/blog'; +import type { Metadata } from 'next'; +import BlogPostContent from './BlogPostContent'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; -function resolveUrl(url: string | null | undefined): string | undefined { - if (!url) return undefined; - if (url.startsWith('http')) return url; - return `${API_BASE_URL}${url}`; + +async function fetchPostMeta(slug: string) { + try { + const res = await fetch(`${API_BASE_URL}/api/v1/blog/${slug}`, { + cache: 'no-store', + }); + if (!res.ok) return null; + return res.json() as Promise<{ + title: string; + excerpt: string; + metaTitle?: string; + metaDescription?: string; + primaryKeyword?: string; + secondaryKeywords?: string[]; + coverImageUrl?: string; + authorName: string; + publishedAt?: string; + updatedAt: string; + }>; + } catch { + return null; + } } -function formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('fr-FR', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); -} +export async function generateMetadata({ + params, +}: { + params: { slug: string; locale: string }; +}): Promise { + const post = await fetchPostMeta(params.slug); -const CATEGORY_LABELS: Record = { - industry: 'Industrie', - technology: 'Technologie', - guides: 'Guides', - news: 'Actualités', -}; - -export default function BlogPostPage() { - const params = useParams(); - const slug = params?.slug as string; - - const [post, setPost] = useState(null); - const [loading, setLoading] = useState(true); - const [notFound, setNotFound] = useState(false); - - useEffect(() => { - if (!slug) return; - - let cancelled = false; - - getBlogPost(slug) - .then(p => { - if (!cancelled) setPost(p); - }) - .catch(() => { - if (!cancelled) setNotFound(true); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - - return () => { - cancelled = true; - }; - }, [slug]); - - if (loading) { - return ( -
- -
-
-
-
-
-
-
- -
- ); + if (!post) { + return { title: 'Blog — Xpeditis' }; } - if (notFound || !post) { - return ( -
- -
- -

Article introuvable

-

- Cet article n'existe pas ou n'est pas encore publié. -

- - - - Retour au blog - - -
- -
- ); - } + 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 ( -
- - - {/* Hero */} -
-
-
-
- -
- - - - - Retour au blog - - - -
- - {CATEGORY_LABELS[post.category] ?? post.category} - - {post.isFeatured && ( - - À la une - - )} -
- -

- {post.title} -

- -

{post.excerpt}

- -
-
- - {post.authorName} -
- {post.publishedAt && ( -
- - {formatDate(post.publishedAt)} -
- )} - {post.tags.length > 0 && ( -
- - {post.tags.join(', ')} -
- )} -
-
-
- -
- - - -
-
- - {/* Cover Image */} - {post.coverImageUrl && ( -
- -
- )} - - {/* Content */} -
-
- - - {/* Tags */} - {post.tags.length > 0 && ( -
- {post.tags.map(tag => ( - - #{tag} - - ))} -
- )} - - {/* Back link */} -
- - - - Retour au blog - - -
-
-
- - -
- ); + 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 function BlogPostPage({ + params, +}: { + params: { slug: string; locale: string }; +}) { + return ; } diff --git a/apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx b/apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx index ec105f7..0e40772 100644 --- a/apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx +++ b/apps/frontend/app/[locale]/dashboard/admin/blog/page.tsx @@ -25,6 +25,10 @@ import { ImageIcon, X, Loader2, + Search, + Clock, + ChevronDown, + ChevronUp, } from 'lucide-react'; import { Link } from '@/i18n/navigation'; @@ -49,17 +53,26 @@ const CATEGORIES: { value: BlogPostCategory; label: string }[] = [ const STATUS_LABELS: Record = { draft: 'Brouillon', + scheduled: 'Planifié', published: 'Publié', archived: 'Archivé', }; const STATUS_COLORS: Record = { draft: 'bg-yellow-100 text-yellow-800', + scheduled: 'bg-blue-100 text-blue-800', published: 'bg-green-100 text-green-800', archived: 'bg-gray-100 text-gray-600', }; -const EMPTY_FORM: CreateBlogPostRequest = { +interface FormData extends CreateBlogPostRequest { + metaTitle: string; + metaDescription: string; + primaryKeyword: string; + secondaryKeywordsInput: string; +} + +const EMPTY_FORM: FormData = { title: '', slug: '', excerpt: '', @@ -68,6 +81,10 @@ const EMPTY_FORM: CreateBlogPostRequest = { category: 'industry', tags: [], authorName: '', + metaTitle: '', + metaDescription: '', + primaryKeyword: '', + secondaryKeywordsInput: '', }; function generateSlug(title: string): string { @@ -80,6 +97,38 @@ function generateSlug(title: string): string { .replace(/\s+/g, '-'); } +function toLocalDatetimeValue(dateStr?: string): string { + if (!dateStr) return ''; + const d = new Date(dateStr); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +// SEO preview snippet +function SeoPreview({ + metaTitle, + metaDescription, + slug, +}: { + metaTitle: string; + metaDescription: string; + slug: string; +}) { + const displayTitle = metaTitle || "Titre de l'article"; + const displayDesc = metaDescription || "Description de l'article..."; + const displayUrl = `xpeditis.com/blog/${slug || 'votre-slug'}`; + return ( +
+

+ Aperçu Google +

+

{displayTitle}

+

{displayUrl}

+

{displayDesc}

+
+ ); +} + export default function AdminBlogPage() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); @@ -92,7 +141,9 @@ export default function AdminBlogPage() { const [uploadingCover, setUploadingCover] = useState(false); const [tagsInput, setTagsInput] = useState(''); const [editStatus, setEditStatus] = useState('draft'); - const [formData, setFormData] = useState(EMPTY_FORM); + const [formData, setFormData] = useState(EMPTY_FORM); + const [scheduledAt, setScheduledAt] = useState(''); + const [seoOpen, setSeoOpen] = useState(false); const coverInputRef = useRef(null); useEffect(() => { @@ -112,19 +163,44 @@ export default function AdminBlogPage() { } }; - const handleCreate = async (e: React.FormEvent) => { + const buildPayload = (withStatus?: BlogPostStatus): CreateBlogPostRequest => { + const tags = tagsInput + ? tagsInput + .split(',') + .map(t => t.trim()) + .filter(Boolean) + : []; + const secondaryKeywords = formData.secondaryKeywordsInput + ? formData.secondaryKeywordsInput + .split(',') + .map(t => t.trim()) + .filter(Boolean) + : []; + return { + title: formData.title, + slug: formData.slug, + excerpt: formData.excerpt, + content: formData.content, + coverImageUrl: formData.coverImageUrl || undefined, + category: formData.category, + tags, + authorName: formData.authorName, + metaTitle: formData.metaTitle || undefined, + metaDescription: formData.metaDescription || undefined, + primaryKeyword: formData.primaryKeyword || undefined, + secondaryKeywords, + scheduledAt: scheduledAt || undefined, + ...(withStatus ? { status: withStatus } : {}), + }; + }; + + const handleCreate = async (e: React.FormEvent, publishNow?: boolean) => { e.preventDefault(); setSaving(true); try { - await createBlogPost({ - ...formData, - tags: tagsInput - ? tagsInput - .split(',') - .map(t => t.trim()) - .filter(Boolean) - : [], - }); + const payload = buildPayload(publishNow ? 'published' : undefined); + if (publishNow) payload.scheduledAt = undefined; + await createBlogPost(payload); await fetchPosts(); closeModal(); } catch (err: any) { @@ -140,14 +216,7 @@ export default function AdminBlogPage() { setSaving(true); try { const data: UpdateBlogPostRequest = { - ...formData, - status: editStatus, - tags: tagsInput - ? tagsInput - .split(',') - .map(t => t.trim()) - .filter(Boolean) - : [], + ...buildPayload(editStatus), }; await updateBlogPost(selectedPost.id, data); await fetchPosts(); @@ -219,8 +288,10 @@ export default function AdminBlogPage() { const openCreate = () => { setFormData(EMPTY_FORM); setTagsInput(''); + setScheduledAt(''); setEditStatus('draft'); setSelectedPost(null); + setSeoOpen(false); setShowCreateModal(true); }; @@ -235,9 +306,15 @@ export default function AdminBlogPage() { category: post.category, tags: post.tags, authorName: post.authorName, + metaTitle: post.metaTitle ?? '', + metaDescription: post.metaDescription ?? '', + primaryKeyword: post.primaryKeyword ?? '', + secondaryKeywordsInput: (post.secondaryKeywords ?? []).join(', '), }); setTagsInput(post.tags.join(', ')); + setScheduledAt(post.status === 'scheduled' ? toLocalDatetimeValue(post.publishedAt) : ''); setEditStatus(post.status); + setSeoOpen(!!(post.metaTitle || post.metaDescription || post.primaryKeyword)); setShowEditModal(true); }; @@ -247,6 +324,8 @@ export default function AdminBlogPage() { setSelectedPost(null); setFormData(EMPTY_FORM); setTagsInput(''); + setScheduledAt(''); + setSeoOpen(false); }; const handleTitleChange = (title: string) => { @@ -257,10 +336,27 @@ export default function AdminBlogPage() { prev.slug === generateSlug(prev.title) || prev.slug === '' ? generateSlug(title) : prev.slug, + metaTitle: prev.metaTitle === prev.title || prev.metaTitle === '' ? title : prev.metaTitle, })); }; + const metaTitleLen = formData.metaTitle.length; + const metaDescLen = formData.metaDescription.length; + const metaTitleColor = + metaTitleLen === 0 + ? 'text-gray-400' + : metaTitleLen <= 60 + ? 'text-green-600' + : 'text-red-500'; + const metaDescColor = + metaDescLen === 0 + ? 'text-gray-400' + : metaDescLen <= 160 + ? 'text-green-600' + : 'text-red-500'; + const isOpen = showCreateModal || showEditModal; + const isScheduleMode = !!scheduledAt; return (
@@ -347,11 +443,25 @@ export default function AdminBlogPage() { {CATEGORIES.find(c => c.value === post.category)?.label ?? post.category} - - {STATUS_LABELS[post.status]} - +
+ + {STATUS_LABELS[post.status]} + + {post.status === 'scheduled' && post.publishedAt && ( + + + {new Date(post.publishedAt).toLocaleString('fr-FR', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + + )} +
{post.authorName} @@ -361,7 +471,7 @@ export default function AdminBlogPage() {
- {post.status === 'published' && ( + {(post.status === 'published' || post.status === 'scheduled') && (
+ + {/* SEO Panel */} +
+ + + {seoOpen && ( +
+
+ +
+ +
+
+ + + {metaTitleLen}/60 + +
+ + setFormData(prev => ({ ...prev, metaTitle: e.target.value })) + } + maxLength={255} + className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:outline-none" + placeholder="Titre affiché dans Google (50-60 car. idéal)" + /> +
+ +
+
+ + + {metaDescLen}/160 + +
+