feat: implement SEO enhancements, automatic slug generation, and font optimizations
All checks were successful
Build and Push App / build (push) Successful in 9m12s
All checks were successful
Build and Push App / build (push) Successful in 9m12s
This commit is contained in:
@@ -13,7 +13,9 @@ interface Props {
|
|||||||
|
|
||||||
export default async function AfroLifePage({ searchParams }: Props) {
|
export default async function AfroLifePage({ searchParams }: Props) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const filter = params.type as string || 'ALL';
|
const rawFilter = params.type as string || 'ALL';
|
||||||
|
const allowedFilters = ['ALL', 'EVENT', 'VIDEO', 'ARTICLE'];
|
||||||
|
const filter = allowedFilters.includes(rawFilter) ? rawFilter : 'ALL';
|
||||||
|
|
||||||
let items: any[] = [];
|
let items: any[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export async function PATCH(
|
|||||||
try {
|
try {
|
||||||
const { id } = await context.params
|
const { id } = await context.params
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
if (body.title && body.slug === undefined) {
|
||||||
|
const { generateSlug } = await import('@/lib/utils')
|
||||||
|
body.slug = generateSlug(body.title)
|
||||||
|
}
|
||||||
const post = await prisma.blogPost.update({ where: { id }, data: body })
|
const post = await prisma.blogPost.update({ where: { id }, data: body })
|
||||||
return NextResponse.json(post)
|
return NextResponse.json(post)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import prisma from '@/lib/prisma'
|
import prisma from '@/lib/prisma'
|
||||||
|
import { generateSlug } from '@/lib/utils'
|
||||||
|
|
||||||
// GET /api/blog
|
// GET /api/blog
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -18,6 +19,9 @@ export async function GET() {
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
if (!body.slug && body.title) {
|
||||||
|
body.slug = generateSlug(body.title)
|
||||||
|
}
|
||||||
const post = await prisma.blogPost.create({ data: body })
|
const post = await prisma.blogPost.create({ data: body })
|
||||||
return NextResponse.json(post, { status: 201 })
|
return NextResponse.json(post, { status: 201 })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Inter', sans-serif;
|
--font-sans: var(--font-inter), sans-serif;
|
||||||
--font-serif: 'Playfair Display', serif;
|
--font-serif: var(--font-playfair), serif;
|
||||||
|
|
||||||
--color-brand-50: #fff7ed;
|
--color-brand-50: #fff7ed;
|
||||||
--color-brand-100: #ffedd5;
|
--color-brand-100: #ffedd5;
|
||||||
|
|||||||
@@ -8,15 +8,32 @@ import CookieBanner from '../components/CookieBanner';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { getSiteSettings } from '../lib/settings';
|
import { getSiteSettings } from '../lib/settings';
|
||||||
|
import { Inter, Playfair_Display } from 'next/font/google';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-inter',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
|
const playfair = Playfair_Display({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--font-playfair',
|
||||||
|
display: 'swap',
|
||||||
|
});
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
return {
|
return {
|
||||||
|
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
||||||
title: `${settings.siteName} - L'Annuaire 2.0`,
|
title: `${settings.siteName} - L'Annuaire 2.0`,
|
||||||
description: settings.siteSlogan,
|
description: settings.siteSlogan || "Découvrez les pépites de l'écosystème africain.",
|
||||||
|
alternates: {
|
||||||
|
canonical: '/',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,12 +41,11 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr" className={`${inter.variable} ${playfair.variable}`} suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="https://cdn-icons-png.flaticon.com/512/1022/1022235.png" />
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet" />
|
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans">
|
<body className="bg-gray-50 text-gray-900 antialiased min-h-screen flex flex-col font-sans" suppressHydrationWarning>
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
<AnalyticsTracker />
|
<AnalyticsTracker />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
|
import { Search, MapPin, Briefcase, TrendingUp, Loader2, ArrowRight } from 'lucide-react';
|
||||||
|
import { generateSlug } from '../lib/utils';
|
||||||
import { Business, BlogPost } from '../types';
|
import { Business, BlogPost } from '../types';
|
||||||
import BusinessCard from '../components/BusinessCard';
|
import BusinessCard from '../components/BusinessCard';
|
||||||
import { useUser } from '../components/UserProvider';
|
import { useUser } from '../components/UserProvider';
|
||||||
@@ -68,7 +69,7 @@ const HomePage = () => {
|
|||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="relative bg-dark-900 overflow-hidden">
|
<div className="relative bg-dark-900 overflow-hidden">
|
||||||
<div className="absolute inset-0 opacity-40">
|
<div className="absolute inset-0 opacity-40">
|
||||||
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" />
|
<img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?ixlib=rb-4.0.3&auto=format&fit=crop&w=1740&q=80" className="w-full h-full object-cover" alt="African entrepreneurs team" width={1740} height={1160} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 lg:py-32">
|
||||||
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
|
<h1 className="text-4xl md:text-6xl font-serif font-bold text-white mb-6 tracking-tight">
|
||||||
@@ -179,9 +180,9 @@ const HomePage = () => {
|
|||||||
})
|
})
|
||||||
.slice(0, settings?.homeBlogCount || 3)
|
.slice(0, settings?.homeBlogCount || 3)
|
||||||
.map(post => (
|
.map(post => (
|
||||||
<Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
<Link key={post.id} href={`/blog/${post.slug ? generateSlug(post.slug) : post.id}`} className="group bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden hover:shadow-md transition-all">
|
||||||
<div className="h-48 overflow-hidden">
|
<div className="h-48 overflow-hidden">
|
||||||
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
|
<img src={post.imageUrl} alt={post.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" width={400} height={300} loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
|
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2 group-hover:text-brand-600 transition-colors">{post.title}</h3>
|
||||||
|
|||||||
21
app/robots.ts
Normal file
21
app/robots.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://afroprenariat.com';
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: '*',
|
||||||
|
allow: '/',
|
||||||
|
disallow: [
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/dashboard',
|
||||||
|
'/subscription',
|
||||||
|
'/api',
|
||||||
|
'/suspended',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sitemap: `${baseUrl}/sitemap.xml`,
|
||||||
|
};
|
||||||
|
}
|
||||||
79
app/sitemap.ts
Normal file
79
app/sitemap.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { MetadataRoute } from 'next';
|
||||||
|
import prisma from '@/lib/prisma';
|
||||||
|
|
||||||
|
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://afroprenariat.com';
|
||||||
|
|
||||||
|
// 1. Static routes
|
||||||
|
const staticRoutes = [
|
||||||
|
{
|
||||||
|
url: `${baseUrl}`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'daily' as const,
|
||||||
|
priority: 1.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/annuaire`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'daily' as const,
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/blog`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'daily' as const,
|
||||||
|
priority: 0.9,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/afrolife`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'weekly' as const,
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/cgu`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'yearly' as const,
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/cgv`,
|
||||||
|
lastModified: new Date(),
|
||||||
|
changeFrequency: 'yearly' as const,
|
||||||
|
priority: 0.3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Fetch all active businesses
|
||||||
|
const businesses = await prisma.business.findMany({
|
||||||
|
where: { isActive: true, isSuspended: false },
|
||||||
|
select: { id: true, slug: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const businessRoutes = businesses.map((business) => ({
|
||||||
|
url: `${baseUrl}/annuaire/${business.slug || business.id}`,
|
||||||
|
lastModified: business.updatedAt,
|
||||||
|
changeFrequency: 'weekly' as const,
|
||||||
|
priority: 0.8,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. Fetch all blog posts
|
||||||
|
const blogPosts = await prisma.blogPost.findMany({
|
||||||
|
select: { id: true, slug: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const blogRoutes = blogPosts.map((post) => ({
|
||||||
|
url: `${baseUrl}/blog/${post.slug || post.id}`,
|
||||||
|
lastModified: post.updatedAt,
|
||||||
|
changeFrequency: 'monthly' as const,
|
||||||
|
priority: 0.7,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...staticRoutes, ...businessRoutes, ...blogRoutes];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error generating sitemap:", error);
|
||||||
|
// Fallback to only static routes if DB connection fails
|
||||||
|
return staticRoutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,9 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
|
|||||||
src={`https://picsum.photos/seed/${business.id}/500/300`}
|
src={`https://picsum.photos/seed/${business.id}/500/300`}
|
||||||
alt="Couverture"
|
alt="Couverture"
|
||||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||||
|
width={500}
|
||||||
|
height={300}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
|
||||||
|
|
||||||
@@ -98,7 +101,7 @@ const BusinessCard: React.FC<{ business: Business }> = ({ business }) => {
|
|||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="absolute -bottom-6 left-4 z-10 w-16 h-16 bg-white rounded-lg p-1 shadow-md">
|
<div className="absolute -bottom-6 left-4 z-10 w-16 h-16 bg-white rounded-lg p-1 shadow-md">
|
||||||
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-md bg-gray-50" />
|
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover rounded-md bg-gray-50" width={64} height={64} loading="lazy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link href={`/annuaire/${business.slug || business.id}`} className="absolute inset-0 z-0" aria-label={`Voir ${business.name}`} />
|
<Link href={`/annuaire/${business.slug || business.id}`} className="absolute inset-0 z-0" aria-label={`Voir ${business.name}`} />
|
||||||
|
|||||||
13
lib/utils.ts
Normal file
13
lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export function generateSlug(text: string): string {
|
||||||
|
return text
|
||||||
|
.toString()
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.normalize('NFD') // remove accents
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/\s+/g, '-') // replace spaces with -
|
||||||
|
.replace(/[^\w-]+/g, '') // remove all non-word chars
|
||||||
|
.replace(/--+/g, '-') // replace multiple - with single -
|
||||||
|
.replace(/^-+/, '') // trim - from start of text
|
||||||
|
.replace(/-+$/, ''); // trim - from end of text
|
||||||
|
}
|
||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -2,6 +2,32 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
poweredByHeader: false,
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/:path*',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'X-Frame-Options',
|
||||||
|
value: 'SAMEORIGIN',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Content-Security-Policy',
|
||||||
|
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; media-src 'self' https:; connect-src 'self' wss: https:; base-uri 'self'; form-action 'self'; frame-ancestors 'self';",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'X-Content-Type-Options',
|
||||||
|
value: 'nosniff',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Referrer-Policy',
|
||||||
|
value: 'strict-origin-when-cross-origin',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
1215
package-lock.json
generated
1215
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -48,5 +48,9 @@
|
|||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"postcss": "^8.5.10",
|
||||||
|
"@hono/node-server": "^1.19.13"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
46
scripts/backfill-slugs.ts
Normal file
46
scripts/backfill-slugs.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import prisma from '../lib/prisma'
|
||||||
|
import { generateSlug } from '../lib/utils'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const posts = await prisma.blogPost.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ slug: null },
|
||||||
|
{ slug: '' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`Found ${posts.length} posts without slug.`)
|
||||||
|
|
||||||
|
for (const post of posts) {
|
||||||
|
const newSlug = generateSlug(post.title)
|
||||||
|
|
||||||
|
// Handle potential duplicates
|
||||||
|
let slug = newSlug
|
||||||
|
let count = 1
|
||||||
|
let unique = false
|
||||||
|
|
||||||
|
while (!unique) {
|
||||||
|
const existing = await prisma.blogPost.findUnique({ where: { slug } })
|
||||||
|
if (!existing || existing.id === post.id) {
|
||||||
|
unique = true
|
||||||
|
} else {
|
||||||
|
slug = `${newSlug}-${count}`
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.blogPost.update({
|
||||||
|
where: { id: post.id },
|
||||||
|
data: { slug }
|
||||||
|
})
|
||||||
|
console.log(`Updated post: ${post.title} -> ${slug}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user