feat: initialize Prisma schema and scaffold core application modules including auth, blogs, events, and business directories
Some checks failed
Build and Push App / build (push) Failing after 47s

This commit is contained in:
2026-04-22 22:59:12 +02:00
parent b01b2f6476
commit 2e7cb65a29
32 changed files with 1392 additions and 310 deletions

View File

@@ -121,32 +121,40 @@ model Offer {
} }
model BlogPost { model BlogPost {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
excerpt String slug String? @unique
content String excerpt String
author String content String
date DateTime @default(now()) author String
imageUrl String date DateTime @default(now())
createdAt DateTime @default(now()) imageUrl String
updatedAt DateTime @updatedAt tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Interview { model Interview {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
guestName String slug String? @unique
companyName String guestName String
role String companyName String
type InterviewType role String
thumbnailUrl String type InterviewType
videoUrl String? thumbnailUrl String
content String? videoUrl String?
excerpt String content String?
date DateTime @default(now()) excerpt String
duration String? date DateTime @default(now())
createdAt DateTime @default(now()) duration String?
updatedAt DateTime @updatedAt tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Comment { model Comment {
@@ -288,3 +296,19 @@ model LegalDocument {
content String @db.Text content String @db.Text
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Event {
id String @id @default(uuid())
title String
slug String? @unique
description String @db.Text
date DateTime
location String
thumbnailUrl String
link String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -2,18 +2,27 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils";
export async function createBlogPost(data: { export async function createBlogPost(data: {
title: string; title: string;
slug?: string;
excerpt: string; excerpt: string;
content: string; content: string;
author: string; author: string;
imageUrl: string; imageUrl: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title);
const post = await prisma.blogPost.create({ const post = await prisma.blogPost.create({
data: { data: {
...data, ...data,
slug,
tags: data.tags || [],
}, },
}); });
revalidatePath("/blog"); revalidatePath("/blog");
@@ -26,15 +35,25 @@ export async function createBlogPost(data: {
export async function updateBlogPost(id: string, data: { export async function updateBlogPost(id: string, data: {
title: string; title: string;
slug?: string;
excerpt: string; excerpt: string;
content: string; content: string;
author: string; author: string;
imageUrl: string; imageUrl: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title);
const post = await prisma.blogPost.update({ const post = await prisma.blogPost.update({
where: { id }, where: { id },
data, data: {
...data,
slug,
tags: data.tags || [],
},
}); });
revalidatePath("/blog"); revalidatePath("/blog");
return { success: true, data: post }; return { success: true, data: post };

View File

@@ -0,0 +1,82 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { generateSlug } from "@/lib/utils";
export async function createEvent(data: {
title: string;
slug?: string;
description: string;
date: Date;
location: string;
thumbnailUrl: string;
link?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const event = await prisma.event.create({
data: {
...data,
slug,
tags: data.tags || [],
},
});
revalidatePath("/blog");
revalidatePath("/afrolife");
return { success: true, data: event };
} catch (error) {
console.error("Failed to create event:", error);
return { success: false, error: "Erreur lors de la création de l'événement" };
}
}
export async function updateEvent(id: string, data: {
title: string;
slug?: string;
description: string;
date: Date;
location: string;
thumbnailUrl: string;
link?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
}) {
try {
const slug = data.slug || generateSlug(data.title);
const event = await prisma.event.update({
where: { id },
data: {
...data,
slug,
tags: data.tags || [],
},
});
revalidatePath("/blog");
revalidatePath("/afrolife");
return { success: true, data: event };
} catch (error) {
console.error("Failed to update event:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteEvent(id: string) {
try {
await prisma.event.delete({
where: { id },
});
revalidatePath("/blog");
revalidatePath("/afrolife");
return { success: true };
} catch (error) {
console.error("Failed to delete event:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -3,9 +3,11 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { InterviewType } from "@prisma/client"; import { InterviewType } from "@prisma/client";
import { generateSlug } from "@/lib/utils";
export async function createInterview(data: { export async function createInterview(data: {
title: string; title: string;
slug?: string;
guestName: string; guestName: string;
companyName: string; companyName: string;
role: string; role: string;
@@ -15,10 +17,19 @@ export async function createInterview(data: {
content?: string; content?: string;
excerpt: string; excerpt: string;
duration?: string; duration?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title);
const interview = await prisma.interview.create({ const interview = await prisma.interview.create({
data, data: {
...data,
slug,
tags: data.tags || [],
},
}); });
revalidatePath("/blog"); revalidatePath("/blog");
return { success: true, data: interview }; return { success: true, data: interview };
@@ -30,6 +41,7 @@ export async function createInterview(data: {
export async function updateInterview(id: string, data: { export async function updateInterview(id: string, data: {
title: string; title: string;
slug?: string;
guestName: string; guestName: string;
companyName: string; companyName: string;
role: string; role: string;
@@ -39,11 +51,20 @@ export async function updateInterview(id: string, data: {
content?: string; content?: string;
excerpt: string; excerpt: string;
duration?: string; duration?: string;
tags?: string[];
metaTitle?: string;
metaDescription?: string;
}) { }) {
try { try {
const slug = data.slug || generateSlug(data.title);
const interview = await prisma.interview.update({ const interview = await prisma.interview.update({
where: { id }, where: { id },
data, data: {
...data,
slug,
tags: data.tags || [],
},
}); });
revalidatePath("/blog"); revalidatePath("/blog");
return { success: true, data: interview }; return { success: true, data: interview };

View File

@@ -1,34 +1,40 @@
import { prisma } from '@/lib/prisma'; import { prisma } from '@/lib/prisma';
import Link from 'next/link'; import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit } from 'lucide-react'; import { Plus, BookOpen, Mic2, Trash2, Edit, Calendar, MapPin } from 'lucide-react';
import DeleteBlogButton from '@/components/DeleteBlogButton'; import DeleteBlogButton from '@/components/DeleteBlogButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton'; import DeleteInterviewButton from '@/components/DeleteInterviewButton';
import DeleteEventButton from '@/components/DeleteEventButton';
async function getData() { async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } }); const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
const interviews = await prisma.interview.findMany({ orderBy: { createdAt: 'desc' } }); const interviews = await prisma.interview.findMany({ orderBy: { createdAt: 'desc' } });
return { posts, interviews }; const events = await prisma.event.findMany({ orderBy: { date: 'desc' } });
return { posts, interviews, events };
} }
export default async function BlogCMSPage() { export default async function BlogCMSPage() {
const { posts, interviews } = await getData(); const { posts, interviews, events } = await getData();
return ( return (
<div> <div className="space-y-8">
<div className="flex justify-between items-end mb-8"> <div className="flex justify-between items-end mb-8">
<div> <div>
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog & Interviews</h1> <h1 className="text-3xl font-bold text-white mb-2">CMS Blog, Interviews & Événements</h1>
<p className="text-slate-400">Gérez le contenu éditorial de la plateforme.</p> <p className="text-slate-400">Gérez le contenu éditorial et événementiel de la plateforme.</p>
</div> </div>
<div className="flex gap-4"> <div className="flex flex-wrap gap-4">
<Link href="/blog/new" className="btn-verify flex items-center gap-2"> <Link href="/blog/new" className="btn-verify flex items-center gap-2 bg-indigo-600">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Nouvel Article Nouvel Article
</Link> </Link>
<Link href="/interview/new" className="btn-verify bg-indigo-600 flex items-center gap-2"> <Link href="/interview/new" className="btn-verify bg-emerald-600 flex items-center gap-2">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Nouvelle Interview Nouvelle Interview
</Link> </Link>
<Link href="/event/new" className="btn-verify bg-amber-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvel Événement
</Link>
</div> </div>
</div> </div>
@@ -40,25 +46,25 @@ export default async function BlogCMSPage() {
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2> <h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="data-table"> <table className="data-table w-full">
<thead> <thead>
<tr> <tr>
<th>Titre</th> <th className="text-left py-3 px-4">Titre</th>
<th>Auteur</th> <th className="text-left py-3 px-4">Auteur</th>
<th>Date</th> <th className="text-left py-3 px-4">Date</th>
<th className="text-right">Actions</th> <th className="text-right py-3 px-4">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-slate-800">
{posts.map((post) => ( {posts.map((post) => (
<tr key={post.id}> <tr key={post.id} className="hover:bg-slate-800/30 transition-colors">
<td> <td className="py-4 px-4">
<div className="font-medium text-white">{post.title}</div> <div className="font-medium text-white">{post.title}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div> <div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
</td> </td>
<td className="text-sm text-slate-300">{post.author}</td> <td className="py-4 px-4 text-sm text-slate-300">{post.author}</td>
<td className="text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td> <td className="py-4 px-4 text-sm text-slate-500">{new Date(post.createdAt).toLocaleDateString('fr-FR')}</td>
<td className="text-right"> <td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400"> <Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Edit className="w-5 h-5" /> <Edit className="w-5 h-5" />
@@ -80,32 +86,32 @@ export default async function BlogCMSPage() {
<h2 className="text-xl font-bold text-white">Interviews ({interviews.length})</h2> <h2 className="text-xl font-bold text-white">Interviews ({interviews.length})</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="data-table"> <table className="data-table w-full">
<thead> <thead>
<tr> <tr>
<th>Interviewé</th> <th className="text-left py-3 px-4">Interviewé</th>
<th>Entreprise / Rôle</th> <th className="text-left py-3 px-4">Entreprise / Rôle</th>
<th>Type</th> <th className="text-left py-3 px-4">Type</th>
<th className="text-right">Actions</th> <th className="text-right py-3 px-4">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-slate-800">
{interviews.map((interview) => ( {interviews.map((interview) => (
<tr key={interview.id}> <tr key={interview.id} className="hover:bg-slate-800/30 transition-colors">
<td> <td className="py-4 px-4">
<div className="font-medium text-white">{interview.guestName}</div> <div className="font-medium text-white">{interview.guestName}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div> <div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
</td> </td>
<td> <td className="py-4 px-4">
<div className="text-sm text-slate-300">{interview.companyName}</div> <div className="text-sm text-slate-300">{interview.companyName}</div>
<div className="text-xs text-slate-500">{interview.role}</div> <div className="text-xs text-slate-500">{interview.role}</div>
</td> </td>
<td> <td className="py-4 px-4">
<span className={`badge ${interview.type === 'VIDEO' ? 'badge-verified' : 'badge-pending'}`}> <span className={`px-2 py-1 rounded-full text-[10px] font-bold ${interview.type === 'VIDEO' ? 'bg-indigo-500/10 text-indigo-400' : 'bg-emerald-500/10 text-emerald-400'}`}>
{interview.type} {interview.type}
</span> </span>
</td> </td>
<td className="text-right"> <td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link href={`/interview/edit/${interview.id}`} className="p-2 text-slate-400 hover:text-emerald-400"> <Link href={`/interview/edit/${interview.id}`} className="p-2 text-slate-400 hover:text-emerald-400">
<Edit className="w-5 h-5" /> <Edit className="w-5 h-5" />
@@ -119,6 +125,55 @@ export default async function BlogCMSPage() {
</table> </table>
</div> </div>
</div> </div>
{/* Events Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<Calendar className="text-amber-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Événements ({events.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table w-full">
<thead>
<tr>
<th className="text-left py-3 px-4">Événement</th>
<th className="text-left py-3 px-4">Date & Lieu</th>
<th className="text-right py-3 px-4">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{events.map((event) => (
<tr key={event.id} className="hover:bg-slate-800/30 transition-colors">
<td className="py-4 px-4">
<div className="font-medium text-white">{event.title}</div>
<div className="text-xs text-slate-500 truncate max-w-sm">
{event.link ? 'Lien activé' : 'Pas de lien'}
</div>
</td>
<td className="py-4 px-4">
<div className="flex items-center text-sm text-slate-300 gap-2">
<Calendar className="w-3 h-3 text-slate-500" />
{new Date(event.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div>
<div className="flex items-center text-xs text-slate-500 gap-2 mt-1">
<MapPin className="w-3 h-3" />
{event.location}
</div>
</td>
<td className="py-4 px-4 text-right">
<div className="flex justify-end gap-2">
<Link href={`/event/edit/${event.id}`} className="p-2 text-slate-400 hover:text-amber-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteEventButton id={event.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,20 @@
import EventForm from "@/components/EventForm";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ id: string }>;
}
export default async function EditEventPage({ params }: Props) {
const { id } = await params;
const event = await prisma.event.findUnique({
where: { id },
});
if (!event) {
notFound();
}
return <EventForm initialData={event} />;
}

View File

@@ -0,0 +1,5 @@
import EventForm from "@/components/EventForm";
export default function NewEventPage() {
return <EventForm />;
}

View File

@@ -125,6 +125,26 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0; prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
} }
// 5. Recent Unique IPs
const recentUniqueIPs = await prisma.analyticsEvent.groupBy({
by: ['ip'],
where: currentWhere,
_max: { createdAt: true },
orderBy: { _max: { createdAt: 'desc' } },
take: 20
});
const recentIPs = await Promise.all(
recentUniqueIPs.map(async (item) => {
return await prisma.analyticsEvent.findFirst({
where: {
ip: item.ip,
createdAt: item._max.createdAt
}
});
})
);
const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0; const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0;
return { return {
@@ -134,6 +154,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
browsers, browsers,
totalEvents, totalEvents,
uniqueVisitors, uniqueVisitors,
recentIPs,
deltas: { deltas: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null, events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null, visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
@@ -147,7 +168,7 @@ async function getMetrics(range: string = 'week', from?: string, to?: string) {
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) { export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
const searchParams = await searchParamsPromise; const searchParams = await searchParamsPromise;
const range = searchParams.range || 'week'; const range = searchParams.range || 'week';
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, deltas } = await getMetrics(range, searchParams.from, searchParams.to); const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, recentIPs, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
const rangeLabels: Record<string, string> = { const rangeLabels: Record<string, string> = {
day: 'Dernières 24h', day: 'Dernières 24h',
@@ -331,7 +352,7 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
</div> </div>
</div> </div>
{/* Top Pages Table (Moved and compact) */} {/* Top Pages Table (Restored) */}
<div className="card p-0 overflow-hidden lg:col-span-2"> <div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between"> <div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2"> <h2 className="text-xl font-bold text-white flex items-center gap-2">
@@ -357,6 +378,70 @@ export default async function MetricsPage({ searchParams: searchParamsPromise }:
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Recent IP Connections Table */}
<div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-indigo-400" /> Dernières Connexions (IP)
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Adresse IP</th>
<th className="px-6 py-4">Localisation</th>
<th className="px-6 py-4 text-center">Appareil / OS</th>
<th className="px-6 py-4">Navigateur</th>
<th className="px-6 py-4">Dernière Page</th>
<th className="px-6 py-4 text-right">Date / Heure</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{recentIPs.map((e, idx) => e && (
<tr key={`${e.ip}-${idx}`} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<span className="font-mono text-xs text-indigo-400 bg-indigo-400/5 px-2 py-1 rounded border border-indigo-400/10 whitespace-nowrap">
{e.ip || '---.---.---.---'}
</span>
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-sm text-slate-200 font-bold">{e.country || 'Inconnu'}</span>
<span className="text-[10px] text-slate-500">{e.city || '---'}</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<div className="inline-flex flex-col items-center">
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-bold mb-1 ${
e.device === 'Mobile' ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
}`}>
{e.device}
</span>
<span className="text-[10px] text-slate-400">{e.os}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-300">
{e.browser}
</td>
<td className="px-6 py-4">
<span className="text-xs font-mono text-slate-400 truncate max-w-[150px] block" title={e.path}>
{e.path}
</span>
</td>
<td className="px-6 py-4 text-right text-xs whitespace-nowrap">
<div className="flex flex-col">
<span className="text-slate-300">{new Date(e.createdAt).toLocaleDateString('fr-FR')}</span>
<span className="text-slate-500">{new Date(e.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -16,6 +16,10 @@ interface Props {
content: string; content: string;
author: string; author: string;
imageUrl: string; imageUrl: string;
slug?: string | null;
tags?: string[];
metaTitle?: string | null;
metaDescription?: string | null;
}; };
} }
@@ -29,10 +33,14 @@ export default function BlogForm({ initialData }: Props) {
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const data = { const data = {
title: formData.get('title') as string, title: formData.get('title') as string,
slug: formData.get('slug') as string,
excerpt: formData.get('excerpt') as string, excerpt: formData.get('excerpt') as string,
content: content, content: content,
author: formData.get('author') as string, author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string, imageUrl: formData.get('imageUrl') as string,
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
}; };
startTransition(async () => { startTransition(async () => {
@@ -51,7 +59,7 @@ export default function BlogForm({ initialData }: Props) {
}; };
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors"> <Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
@@ -63,60 +71,112 @@ export default function BlogForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="card space-y-6"> <form onSubmit={handleSubmit} className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: L'essor de la Tech en Afrique"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label>
<input
name="author"
defaultValue={initialData?.author}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Jean Dupont"
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre</label> <label className="text-sm font-medium text-slate-400">URL de l'image</label>
<input <input
name="title" name="imageUrl"
defaultValue={initialData?.title} defaultValue={initialData?.imageUrl}
required required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: L'essor de la Tech en Afrique" placeholder="https://images.unsplash.com/..."
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Auteur</label> <label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label>
<input <textarea
name="author" name="excerpt"
defaultValue={initialData?.author} defaultValue={initialData?.excerpt}
required required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Jean Dupont" placeholder="Un court résumé de l'article..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez votre article ici..."
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> {/* SEO SECTION */}
<label className="text-sm font-medium text-slate-400">URL de l'image</label> <div className="card space-y-6">
<input <h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
name="imageUrl"
defaultValue={initialData?.imageUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://images.unsplash.com/..."
/>
</div>
<div className="space-y-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</label> <div className="space-y-2">
<textarea <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
name="excerpt" <input
defaultValue={initialData?.excerpt} name="slug"
required defaultValue={initialData?.slug || ''}
rows={2} className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" placeholder="ex: lessor-de-la-tech-afrique"
placeholder="Un court résumé de l'article..." />
/> <p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="tech, afrique, innovation"
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu de l'article</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<RichTextEditor <input
value={content} name="metaTitle"
onChange={setContent} defaultValue={initialData?.metaTitle || ''}
placeholder="Rédigez votre article ici..." className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/> placeholder="Titre optimisé pour les moteurs de recherche"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={initialData?.metaDescription || ''}
rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description optimisée pour les moteurs de recherche (max 160 caractères)..."
/>
</div>
</div> </div>
<div className="pt-4"> <div className="pt-4">

View File

@@ -0,0 +1,41 @@
"use client";
import { useTransition } from 'react';
import { deleteEvent } from '@/app/actions/event';
import { Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-hot-toast';
interface Props {
id: string;
}
export default function DeleteEventButton({ id }: Props) {
const [isPending, startTransition] = useTransition();
const handleDelete = async () => {
if (!confirm('Êtes-vous sûr de vouloir supprimer cet événement ?')) return;
startTransition(async () => {
const result = await deleteEvent(id);
if (result.success) {
toast.success("Événement supprimé");
} else {
toast.error(result.error || "Erreur lors de la suppression");
}
});
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="p-2 text-slate-400 hover:text-rose-500 transition-colors"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Trash2 className="w-5 h-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,208 @@
"use client";
import { useTransition, useState } from 'react';
import { useRouter } from 'next/navigation';
import { createEvent, updateEvent } from '@/app/actions/event';
import { Loader2, ArrowLeft, Save, Calendar, MapPin, Link as LinkIcon } from 'lucide-react';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import RichTextEditor from './RichTextEditor';
interface Props {
initialData?: any;
}
export default function EventForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [description, setDescription] = useState(initialData?.description || '');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data: any = {
title: formData.get('title') as string,
slug: formData.get('slug') as string,
location: formData.get('location') as string,
date: new Date(formData.get('date') as string),
thumbnailUrl: formData.get('thumbnailUrl') as string,
link: formData.get('link') as string || null,
description: description || '',
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
};
startTransition(async () => {
const result = initialData
? await updateEvent(initialData.id, data)
: await createEvent(data);
if (result.success) {
toast.success(initialData ? "Événement mis à jour" : "Événement créé avec succès");
router.push('/blog');
router.refresh();
} else {
toast.error(result.error || "Une erreur est survenue");
}
});
};
return (
<div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<h1 className="text-3xl font-bold text-white">
{initialData ? 'Modifier l\'événement' : 'Nouvel Événement'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-8">
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de l'événement</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Conférence AfroHub 2026"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Date de l'événement</label>
<div className="relative">
<Calendar className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="date"
type="datetime-local"
defaultValue={initialData?.date ? new Date(initialData.date).toISOString().slice(0, 16) : ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lieu</label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="location"
defaultValue={initialData?.location}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Abidjan, Côte-d'Ivoire / Zoom"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Lien d'inscription / info (Optionnel)</label>
<div className="relative">
<LinkIcon className="absolute left-3 top-3.5 w-5 h-5 text-slate-500" />
<input
name="link"
defaultValue={initialData?.link}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 pl-10 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://..."
/>
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de l'image (Affiche/Miniature)</label>
<input
name="thumbnailUrl"
defaultValue={initialData?.thumbnailUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Description de l'événement</label>
<RichTextEditor
value={description}
onChange={setDescription}
placeholder="Détails sur l'événement, programme, intervenants..."
/>
</div>
</div>
{/* SEO SECTION */}
<div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
<input
name="slug"
defaultValue={initialData?.slug || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="ex: conference-afrohub-2026"
/>
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="evenement, networking, afrique"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input
name="metaTitle"
defaultValue={initialData?.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Titre optimisé pour Google"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<textarea
name="metaDescription"
defaultValue={initialData?.metaDescription || ''}
rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description courte pour les résultats de recherche..."
/>
</div>
</div>
<div className="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg bg-indigo-600"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'événement'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -23,6 +23,7 @@ export default function InterviewForm({ initialData }: Props) {
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);
const data: any = { const data: any = {
title: formData.get('title') as string, title: formData.get('title') as string,
slug: formData.get('slug') as string,
guestName: formData.get('guestName') as string, guestName: formData.get('guestName') as string,
companyName: formData.get('companyName') as string, companyName: formData.get('companyName') as string,
role: formData.get('role') as string, role: formData.get('role') as string,
@@ -32,6 +33,9 @@ export default function InterviewForm({ initialData }: Props) {
excerpt: formData.get('excerpt') as string, excerpt: formData.get('excerpt') as string,
content: content || null, content: content || null,
duration: formData.get('duration') as string || null, duration: formData.get('duration') as string || null,
tags: (formData.get('tags') as string)?.split(',').map(t => t.trim()).filter(t => t !== ''),
metaTitle: formData.get('metaTitle') as string,
metaDescription: formData.get('metaDescription') as string,
}; };
startTransition(async () => { startTransition(async () => {
@@ -50,7 +54,7 @@ export default function InterviewForm({ initialData }: Props) {
}; };
return ( return (
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto pb-20">
<div className="mb-8 flex items-center justify-between"> <div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors"> <Link href="/blog" className="p-2 bg-slate-800 rounded-lg text-slate-400 hover:text-white transition-colors">
@@ -62,112 +66,164 @@ export default function InterviewForm({ initialData }: Props) {
</div> </div>
</div> </div>
<form onSubmit={handleSubmit} className="card space-y-6"> <form onSubmit={handleSubmit} className="space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="card space-y-6">
<h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Informations Générales</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label>
<input
name="title"
defaultValue={initialData?.title}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Entreprise X : Une success story..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label>
<input
name="guestName"
defaultValue={initialData?.guestName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Sarah Koné"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Entreprise</label>
<input
name="companyName"
defaultValue={initialData?.companyName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: TechAfrica"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input
name="role"
defaultValue={initialData?.role}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Fondatrice & CEO"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Type d'interview</label>
<select
name="type"
defaultValue={initialData?.type || 'VIDEO'}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
>
<option value="VIDEO">Vidéo</option>
<option value="ARTICLE">Article (Texte)</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label>
<input
name="duration"
defaultValue={initialData?.duration}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label>
<input
name="thumbnailUrl"
defaultValue={initialData?.thumbnailUrl}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
<input
name="videoUrl"
defaultValue={initialData?.videoUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..."
/>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Titre de l'interview</label> <label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
<input <textarea
name="title" name="excerpt"
defaultValue={initialData?.title} defaultValue={initialData?.excerpt}
required required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Entreprise X : Une success story..."
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom de l'invité</label> <label className="text-sm font-medium text-slate-400">Contenu / Transcription (Optionnel)</label>
<input <RichTextEditor
name="guestName" value={content}
defaultValue={initialData?.guestName} onChange={setContent}
required placeholder="Rédigez la transcription ou le contenu ici..."
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Sarah Koné"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Entreprise</label>
<input
name="companyName"
defaultValue={initialData?.companyName}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: TechAfrica"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Rôle / Poste</label>
<input
name="role"
defaultValue={initialData?.role}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: Fondatrice & CEO"
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* SEO SECTION */}
<div className="space-y-2"> <div className="card space-y-6">
<label className="text-sm font-medium text-slate-400">Type d'interview</label> <h2 className="text-xl font-semibold text-white border-b border-slate-800 pb-4">Optimisation SEO</h2>
<select
name="type" <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
defaultValue={initialData?.type || 'VIDEO'} <div className="space-y-2">
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" <label className="text-sm font-medium text-slate-400">Slug (URL personnalisée)</label>
> <input
<option value="VIDEO">Vidéo</option> name="slug"
<option value="ARTICLE">Article (Texte)</option> defaultValue={initialData?.slug || ''}
</select> className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="ex: interview-sarah-kone-techafrica"
/>
<p className="text-xs text-slate-500 italic">Laissez vide pour générer automatiquement à partir du titre.</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Mots-clés (tags, séparés par des virgules)</label>
<input
name="tags"
defaultValue={initialData?.tags?.join(', ') || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="interview, succes, tech"
/>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Durée (Optionnel)</label> <label className="text-sm font-medium text-slate-400">Meta Titre (SEO)</label>
<input <input
name="duration" name="metaTitle"
defaultValue={initialData?.duration} defaultValue={initialData?.metaTitle || ''}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Ex: 12 min" placeholder="Titre optimisé SEO"
/> />
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL de la miniature (Thumbnail)</label> <label className="text-sm font-medium text-slate-400">Meta Description (SEO)</label>
<input <textarea
name="thumbnailUrl" name="metaDescription"
defaultValue={initialData?.thumbnailUrl} defaultValue={initialData?.metaDescription || ''}
required rows={3}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500" className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="Description courte SEO..."
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Vidéo (Si type Vidéo)</label>
<input
name="videoUrl"
defaultValue={initialData?.videoUrl}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
placeholder="https://youtube.com/..."
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Extrait (Court résumé)</label>
<textarea
name="excerpt"
defaultValue={initialData?.excerpt}
required
rows={2}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Contenu / Transcription (Optionnel)</label>
<RichTextEditor
value={content}
onChange={setContent}
placeholder="Rédigez la transcription ou le contenu ici..."
/>
</div> </div>
<div className="pt-4"> <div className="pt-4">

View File

@@ -57,7 +57,7 @@ export default function RichTextEditor({ value, onChange, placeholder }: RichTex
const formats = [ const formats = [
'header', 'header',
'bold', 'italic', 'underline', 'strike', 'bold', 'italic', 'underline', 'strike',
'list', 'bullet', 'list',
'divider', 'link', 'divider', 'link',
]; ];

13
admin/src/lib/utils.ts Normal file
View 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
}

View File

@@ -1,25 +1,73 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { ArrowLeft, Share2, Play, Calendar, User, Building2 } from 'lucide-react'; import { ArrowLeft, Share2, Play, Calendar, User, Building2, MapPin, ArrowRight } from 'lucide-react';
import { prisma } from '../../../lib/prisma'; import { prisma } from '../../../lib/prisma';
import { InterviewType } from '@prisma/client'; import { InterviewType } from '@prisma/client';
import { Metadata } from 'next';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
export default async function InterviewDetailPage({ params }: Props) { export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const interview = await prisma.interview.findUnique({ const interview = await prisma.interview.findFirst({
where: { id } where: {
OR: [{ id }, { slug: id }]
}
}); });
if (!interview) { const event = !interview ? await prisma.event.findFirst({
where: {
OR: [{ id }, { slug: id }]
}
}) : null;
const item = interview || event;
if (!item) return { title: 'Contenu non trouvé' };
const title = (item as any).metaTitle || item.title;
const description = (item as any).metaDescription || (item as any).excerpt || (item as any).description;
return {
title,
description,
keywords: (item as any).tags || [],
openGraph: {
title,
description,
images: [(item as any).thumbnailUrl],
}
};
}
export default async function AfroLifeDetailPage({ params }: Props) {
const { id } = await params;
// 1. Try to find an interview
const interview = await prisma.interview.findFirst({
where: {
OR: [{ id }, { slug: id }]
}
});
// 2. If not found, try to find an event
const event = !interview ? await prisma.event.findFirst({
where: {
OR: [{ id }, { slug: id }]
}
}) : null;
if (!interview && !event) {
notFound(); notFound();
} }
const item = interview || event;
const isEvent = !!event;
// Helper to get YouTube embed URL // Helper to get YouTube embed URL
const getEmbedUrl = (url: string) => { const getEmbedUrl = (url: string) => {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/; const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
@@ -27,7 +75,7 @@ export default async function InterviewDetailPage({ params }: Props) {
return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null; return (match && match[2].length === 11) ? `https://www.youtube.com/embed/${match[2]}` : null;
}; };
const isVideo = interview.type === InterviewType.VIDEO; const isVideo = !isEvent && (interview as any).type === InterviewType.VIDEO;
return ( return (
<div className="bg-white min-h-screen"> <div className="bg-white min-h-screen">
@@ -39,26 +87,46 @@ export default async function InterviewDetailPage({ params }: Props) {
{/* Header Info */} {/* Header Info */}
<div className="text-center mb-10"> <div className="text-center mb-10">
<div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4"> <div className="inline-flex items-center justify-center px-3 py-1 rounded-full bg-gray-100 text-gray-600 text-xs font-bold uppercase tracking-wider mb-4">
{isVideo ? 'Interview Vidéo' : 'Grand Entretien'} {isEvent ? 'Événement' : (isVideo ? 'Interview Vidéo' : 'Grand Entretien')}
</div> </div>
<h1 className="text-3xl md:text-5xl font-serif font-bold text-gray-900 mb-6 leading-tight"> <h1 className="text-3xl md:text-5xl font-serif font-bold text-gray-900 mb-6 leading-tight">
{interview.title} {item!.title}
</h1> </h1>
{/* Tags */}
{item && (item as any).tags && (item as any).tags.length > 0 && (
<div className="flex flex-wrap justify-center gap-2 mb-8">
{(item as any).tags.map((tag: string) => (
<span key={tag} className="px-3 py-1 bg-brand-50 text-brand-700 rounded-full text-xs font-semibold">
#{tag}
</span>
))}
</div>
)}
<div className="flex flex-wrap justify-center gap-6 text-sm text-gray-500"> <div className="flex flex-wrap justify-center gap-6 text-sm text-gray-500">
<div className="flex items-center"> {!isEvent ? (
<User className="w-4 h-4 mr-2 text-brand-500" /> <>
<span className="font-medium text-gray-900">{interview.guestName}</span> <div className="flex items-center">
<span className="mx-1 text-gray-400">|</span> <User className="w-4 h-4 mr-2 text-brand-500" />
<span>{interview.role}</span> <span className="font-medium text-gray-900">{(interview as any).guestName}</span>
</div> <span className="mx-1 text-gray-400">|</span>
<div className="flex items-center"> <span>{(interview as any).role}</span>
<Building2 className="w-4 h-4 mr-2 text-brand-500" /> </div>
{interview.companyName} <div className="flex items-center">
</div> <Building2 className="w-4 h-4 mr-2 text-brand-500" />
{(interview as any).companyName}
</div>
</>
) : (
<div className="flex items-center">
<MapPin className="w-4 h-4 mr-2 text-brand-500" />
<span className="font-medium text-gray-900">{(event as any).location}</span>
</div>
)}
<div className="flex items-center"> <div className="flex items-center">
<Calendar className="w-4 h-4 mr-2 text-brand-500" /> <Calendar className="w-4 h-4 mr-2 text-brand-500" />
{new Date(interview.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} {new Date(item!.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</div> </div>
</div> </div>
</div> </div>
@@ -66,10 +134,10 @@ export default async function InterviewDetailPage({ params }: Props) {
{/* Main Content Area */} {/* Main Content Area */}
{isVideo ? ( {isVideo ? (
<div className="bg-black rounded-xl overflow-hidden shadow-2xl aspect-w-16 aspect-h-9 mb-10"> <div className="bg-black rounded-xl overflow-hidden shadow-2xl aspect-w-16 aspect-h-9 mb-10">
{interview.videoUrl ? ( {(interview as any).videoUrl ? (
<iframe <iframe
src={getEmbedUrl(interview.videoUrl) || ''} src={getEmbedUrl((interview as any).videoUrl) || ''}
title={interview.title} title={item!.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen allowFullScreen
className="w-full h-full" className="w-full h-full"
@@ -82,27 +150,44 @@ export default async function InterviewDetailPage({ params }: Props) {
</div> </div>
) : ( ) : (
<div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10"> <div className="relative h-64 md:h-96 rounded-xl overflow-hidden shadow-lg mb-10">
<img src={interview.thumbnailUrl} alt={interview.title} className="w-full h-full object-cover" /> <img src={item!.thumbnailUrl} alt={item!.title} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div> <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
</div> </div>
)} )}
{/* Description / Article Content */} {/* Description / Article Content / Event Content */}
<div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden"> <div className="prose prose-lg prose-orange max-w-none mx-auto text-gray-600 break-words overflow-hidden">
{!isVideo && interview.content ? ( {isEvent ? (
<div dangerouslySetInnerHTML={{ __html: interview.content }} /> <div dangerouslySetInnerHTML={{ __html: (event as any).description }} />
) : ( ) : (
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2"> !isVideo && (interview as any).content ? (
{interview.excerpt} <div dangerouslySetInnerHTML={{ __html: (interview as any).content }} />
</p> ) : (
<p className="text-xl font-serif italic text-gray-500 border-l-4 border-brand-500 pl-6 py-2">
{(interview as any).excerpt}
</p>
)
)} )}
</div> </div>
{/* Event Link Action */}
{isEvent && (event as any).link && (
<div className="mt-10 text-center">
<Link
href={(event as any).link}
target="_blank"
className="inline-flex items-center px-8 py-4 bg-brand-600 text-white rounded-full font-bold shadow-lg hover:bg-brand-700 hover:shadow-xl transition-all"
>
Participer à l'événement <ArrowRight className="w-5 h-5 ml-2" />
</Link>
</div>
)}
{/* Footer Actions */} {/* Footer Actions */}
<div className="mt-12 pt-8 border-t border-gray-100 flex justify-center"> <div className="mt-12 pt-8 border-t border-gray-100 flex justify-center">
<button className="flex items-center px-6 py-3 rounded-full bg-brand-50 text-brand-700 font-medium hover:bg-brand-100 transition-colors"> <button className="flex items-center px-6 py-3 rounded-full bg-brand-50 text-brand-700 font-medium hover:bg-brand-100 transition-colors">
<Share2 className="w-5 h-5 mr-2" /> <Share2 className="w-5 h-5 mr-2" />
Partager cette interview Partager {isEvent ? "cet événement" : "cette interview"}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { Play, FileText, Clock, Mic } from 'lucide-react'; import { Play, FileText, Clock, Mic, Calendar } from 'lucide-react';
import { prisma } from '../../lib/prisma'; import { prisma } from '../../lib/prisma';
import { InterviewType } from '@prisma/client'; import { InterviewType } from '@prisma/client';
import EventTimeline from '../../components/EventTimeline';
export const revalidate = 3600; export const revalidate = 3600;
@@ -12,23 +13,56 @@ 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 InterviewType | 'ALL' || 'ALL'; const filter = params.type as string || 'ALL';
const where: any = {}; let items: any[] = [];
if (filter !== 'ALL') {
where.type = filter;
}
let interviews: any[] = [];
try { try {
interviews = await prisma.interview.findMany({ if (filter === 'EVENT') {
where, const events = await prisma.event.findMany({
orderBy: { createdAt: 'desc' } orderBy: { date: 'desc' }
}); });
items = events.map(e => ({
...e,
guestName: 'Événement',
companyName: e.location,
excerpt: e.description,
type: 'EVENT',
duration: new Date(e.date).toLocaleDateString('fr-FR')
}));
} else {
const where: any = {};
if (filter !== 'ALL') {
where.type = filter as InterviewType;
}
const interviews = await prisma.interview.findMany({
where,
orderBy: { createdAt: 'desc' }
});
items = interviews.map(i => ({ ...i }));
// If 'ALL', we might want to also fetch events and mix them
if (filter === 'ALL') {
const events = await prisma.event.findMany({
orderBy: { date: 'desc' },
take: 10
});
const mappedEvents = events.map(e => ({
...e,
guestName: 'Événement',
companyName: e.location,
excerpt: e.description,
type: 'EVENT',
duration: new Date(e.date).toLocaleDateString('fr-FR')
}));
items = [...items, ...mappedEvents].sort((a, b) =>
new Date(b.createdAt || b.date).getTime() - new Date(a.createdAt || a.date).getTime()
);
}
}
} catch (error) { } catch (error) {
console.error("Database connection failed on Afro Life page."); console.error("Database connection failed on Afro Life page:", error);
interviews = []; items = [];
} }
return ( return (
@@ -68,35 +102,48 @@ export default async function AfroLifePage({ searchParams }: Props) {
> >
<FileText className="w-4 h-4 mr-2" /> Articles <FileText className="w-4 h-4 mr-2" /> Articles
</Link> </Link>
<Link
href="/afrolife?type=EVENT"
className={`flex items-center px-6 py-2 rounded-full text-sm font-medium transition-all ${filter === 'EVENT' ? 'bg-brand-600 text-white shadow-md' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Calendar className="w-4 h-4 mr-2" /> Événements
</Link>
</div> </div>
{/* Grid */} {/* Grid or Timeline */}
{interviews.length === 0 ? ( {items.length === 0 ? (
<div className="text-center py-20 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200"> <div className="text-center py-20 bg-gray-50 rounded-2xl border-2 border-dashed border-gray-200">
<p className="text-gray-500">Aucune interview trouvée dans cette catégorie.</p> <p className="text-gray-500">Aucun contenu trouvé dans cette catégorie.</p>
</div> </div>
) : filter === 'EVENT' ? (
<EventTimeline events={items} />
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{interviews.map(interview => ( {items.map(item => (
<Link href={`/afrolife/${interview.id}`} key={interview.id} className="group block h-full"> <Link href={`/afrolife/${(item as any).slug || item.id}`} key={item.id} className="group block h-full">
<div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300"> <div className="relative h-64 rounded-xl overflow-hidden shadow-sm group-hover:shadow-xl transition-all duration-300">
<img <img
src={interview.thumbnailUrl} src={item.thumbnailUrl}
alt={interview.title} alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/> />
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div> <div className="absolute inset-0 bg-black/20 group-hover:bg-black/10 transition-colors"></div>
{/* Type Badge */} {/* Type Badge */}
<div className="absolute top-4 left-4"> <div className="absolute top-4 left-4">
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${interview.type === InterviewType.VIDEO ? 'bg-red-600/90' : 'bg-blue-600/90'}`}> <span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide text-white backdrop-blur-md ${
{interview.type === InterviewType.VIDEO ? <Play className="w-3 h-3 mr-1 fill-current" /> : <Mic className="w-3 h-3 mr-1" />} item.type === 'VIDEO' ? 'bg-red-600/90' :
{interview.type === InterviewType.VIDEO ? 'Vidéo' : 'Interview'} item.type === 'EVENT' ? 'bg-amber-600/90' : 'bg-blue-600/90'
}`}>
{item.type === 'VIDEO' ? <Play className="w-3 h-3 mr-1 fill-current" /> :
item.type === 'EVENT' ? <Calendar className="w-3 h-3 mr-1" /> : <Mic className="w-3 h-3 mr-1" />}
{item.type === 'VIDEO' ? 'Vidéo' :
item.type === 'EVENT' ? 'Événement' : 'Interview'}
</span> </span>
</div> </div>
{/* Play Icon Overlay for Videos */} {/* Play Icon Overlay for Videos */}
{interview.type === InterviewType.VIDEO && ( {item.type === 'VIDEO' && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300"> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50"> <div className="w-16 h-16 bg-white/30 backdrop-blur-sm rounded-full flex items-center justify-center border border-white/50">
<Play className="w-8 h-8 text-white fill-current ml-1" /> <Play className="w-8 h-8 text-white fill-current ml-1" />
@@ -107,18 +154,16 @@ export default async function AfroLifePage({ searchParams }: Props) {
<div className="pt-6 px-2"> <div className="pt-6 px-2">
<div className="flex items-center text-xs text-gray-500 mb-3 space-x-3"> <div className="flex items-center text-xs text-gray-500 mb-3 space-x-3">
<span className="font-semibold text-brand-600 uppercase">{interview.guestName}</span> <span className="font-semibold text-brand-600 uppercase">{item.guestName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span> <span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span>{interview.companyName}</span> <span className="truncate max-w-[100px]">{item.companyName}</span>
<span className="w-1 h-1 bg-gray-300 rounded-full"></span> <span className="w-1 h-1 bg-gray-300 rounded-full"></span>
<span className="flex items-center"><Clock className="w-3 h-3 mr-1"/> {interview.duration || 'N/A'}</span> <span className="flex items-center flex-nowrap"><Clock className="w-3 h-3 mr-1 shrink-0"/> {item.duration || 'N/A'}</span>
</div> </div>
<h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight line-clamp-2"> <h3 className="text-xl font-serif font-bold text-gray-900 mb-2 group-hover:text-brand-600 transition-colors leading-tight line-clamp-2">
{interview.title} {item.title}
</h3> </h3>
<p className="text-gray-600 text-sm line-clamp-2"> <div className="text-gray-600 text-sm line-clamp-2 prose-sm" dangerouslySetInnerHTML={{ __html: item.excerpt }} />
{interview.excerpt}
</p>
</div> </div>
</Link> </Link>
))} ))}

View File

@@ -34,13 +34,12 @@ const AnnuairePageContent = () => {
useEffect(() => { useEffect(() => {
const fetchHeadliner = async () => { const fetchHeadliner = async () => {
try { try {
const res = await fetch('/api/businesses?featured=true'); // Disable caching to ensure we get fresh data and more variety
const res = await fetch('/api/businesses?featured=true', { cache: 'no-store' });
const data = await res.json(); const data = await res.json();
if (Array.isArray(data) && data.length > 0) { if (Array.isArray(data) && data.length > 0) {
// Stable random based on the current date (YYYYMMDD) // Truly random selection from the pool of featured businesses
const today = new Date().toISOString().slice(0, 10).replace(/-/g, ''); const randomIndex = Math.floor(Math.random() * data.length);
const seed = parseInt(today);
const randomIndex = seed % data.length;
setFeaturedBusiness(data[randomIndex]); setFeaturedBusiness(data[randomIndex]);
} }
} catch (error) { } catch (error) {

11
app/api/settings/route.ts Normal file
View File

@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server';
import { getSiteSettings } from '../../../lib/settings';
export async function GET() {
try {
const settings = await getSiteSettings();
return NextResponse.json(settings);
} catch (error) {
return NextResponse.json({ error: 'Failed to fetch settings' }, { status: 500 });
}
}

View File

@@ -4,15 +4,49 @@ import { notFound } from 'next/navigation';
import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react'; import { ArrowLeft, User, Calendar, Share2 } from 'lucide-react';
import { prisma } from '../../../lib/prisma'; import { prisma } from '../../../lib/prisma';
import { Metadata } from 'next';
interface Props { interface Props {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
} }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await prisma.blogPost.findFirst({
where: {
OR: [
{ id: id },
{ slug: id }
]
}
});
if (!post) return { title: 'Article non trouvé' };
return {
title: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
keywords: post.tags,
openGraph: {
title: post.metaTitle || post.title,
description: post.metaDescription || post.excerpt,
images: [post.imageUrl],
type: 'article',
}
};
}
export default async function BlogPostPage({ params }: Props) { export default async function BlogPostPage({ params }: Props) {
const { id } = await params; const { id } = await params;
const post = await prisma.blogPost.findUnique({ const post = await prisma.blogPost.findFirst({
where: { id } where: {
OR: [
{ id: id },
{ slug: id }
]
}
}); });
if (!post) { if (!post) {
@@ -49,9 +83,17 @@ export default async function BlogPostPage({ params }: Props) {
<Calendar className="w-4 h-4 mr-1 text-brand-500" /> <Calendar className="w-4 h-4 mr-1 text-brand-500" />
{new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} {new Date(post.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</span> </span>
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide"> {post.tags.length > 0 ? (
Conseils post.tags.map(tag => (
</span> <span key={tag} className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
{tag}
</span>
))
) : (
<span className="px-2 py-1 bg-brand-50 text-brand-700 rounded text-xs font-semibold uppercase tracking-wide">
Article
</span>
)}
</div> </div>
<h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight"> <h1 className="text-3xl md:text-4xl font-serif font-bold text-gray-900 leading-tight">
{post.title} {post.title}

View File

@@ -34,7 +34,7 @@ export default async function BlogPage() {
) : ( ) : (
<div className="grid gap-8 lg:grid-cols-3"> <div className="grid gap-8 lg:grid-cols-3">
{posts.map(post => ( {posts.map(post => (
<Link key={post.id} href={`/blog/${post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300"> <Link key={post.id} href={`/blog/${post.slug || post.id}`} className="group flex flex-col rounded-lg shadow-lg overflow-hidden bg-white hover:shadow-xl transition-shadow duration-300">
<div className="flex-shrink-0 relative overflow-hidden h-48"> <div className="flex-shrink-0 relative overflow-hidden h-48">
<img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} /> <img className="h-full w-full object-cover group-hover:scale-105 transition-transform duration-500" src={post.imageUrl} alt={post.title} />
<div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div> <div className="absolute inset-0 bg-black/10 group-hover:bg-transparent transition-colors"></div>

View File

@@ -1,10 +1,16 @@
import React from 'react'; import React from 'react';
import { getLegalDocument } from '@/lib/legal'; import { getLegalDocument } from '@/lib/legal';
export const metadata = { import { getSiteSettings } from '@/lib/settings';
title: 'Conditions Générales d\'Utilisation - Afrohub', import { Metadata } from 'next';
description: 'Consultez les conditions générales d\'utilisation de la plateforme Afrohub.',
}; export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings();
return {
title: `Conditions Générales d'Utilisation - ${settings.siteName}`,
description: `Consultez les conditions générales d'utilisation de la plateforme ${settings.siteName}.`,
};
}
export default async function CGUPage() { export default async function CGUPage() {
const doc = await getLegalDocument('CGU'); const doc = await getLegalDocument('CGU');

View File

@@ -1,10 +1,16 @@
import React from 'react'; import React from 'react';
import { getLegalDocument } from '@/lib/legal'; import { getLegalDocument } from '@/lib/legal';
export const metadata = { import { getSiteSettings } from '@/lib/settings';
title: 'Conditions Générales de Vente - Afrohub', import { Metadata } from 'next';
description: 'Consultez les conditions générales de vente des services Afrohub.',
}; export async function generateMetadata(): Promise<Metadata> {
const settings = await getSiteSettings();
return {
title: `Conditions Générales de Vente - ${settings.siteName}`,
description: `Consultez les conditions générales de vente des services ${settings.siteName}.`,
};
}
export default async function CGVPage() { export default async function CGVPage() {
const doc = await getLegalDocument('CGV'); const doc = await getLegalDocument('CGV');

View File

@@ -15,7 +15,8 @@ import PricingSection from '../../components/PricingSection';
import { useUser } from '../../components/UserProvider'; import { useUser } from '../../components/UserProvider';
const DashboardContent = () => { const DashboardContent = () => {
const { user, logout } = useUser(); const { user, settings, logout } = useUser();
const siteName = settings?.siteName || "Afrohub";
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const chatId = searchParams.get('chatId') || searchParams.get('chat'); const chatId = searchParams.get('chatId') || searchParams.get('chat');
@@ -112,11 +113,11 @@ const DashboardContent = () => {
// Update document title with unread count // Update document title with unread count
useEffect(() => { useEffect(() => {
if (unreadCount > 0) { if (unreadCount > 0) {
document.title = `(${unreadCount}) Messages - Afrohub`; document.title = `(${unreadCount}) Messages - ${siteName}`;
} else { } else {
document.title = 'Tableau de Bord - Afrohub'; document.title = `Tableau de Bord - ${siteName}`;
} }
}, [unreadCount]); }, [unreadCount, siteName]);
useEffect(() => { useEffect(() => {
if (!user && isMounted) { if (!user && isMounted) {
@@ -168,8 +169,10 @@ const DashboardContent = () => {
<div className="hidden md:flex md:flex-col md:w-64 md:fixed md:inset-y-0 bg-white border-r border-gray-200"> <div className="hidden md:flex md:flex-col md:w-64 md:fixed md:inset-y-0 bg-white border-r border-gray-200">
<div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200"> <div className="flex items-center h-16 flex-shrink-0 px-4 border-b border-gray-200">
<Link href="/" className="flex items-center gap-2"> <Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div> <div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">
<span className="font-serif font-bold text-lg text-gray-900">Afrohub</span> {siteName.charAt(0)}
</div>
<span className="font-serif font-bold text-lg text-gray-900">{siteName}</span>
</Link> </Link>
</div> </div>
<div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4"> <div className="flex-1 flex flex-col overflow-y-auto pt-5 pb-4">

View File

@@ -12,10 +12,13 @@ import './globals.css';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: 'Afrohub - L\'Annuaire 2.0', const settings = await getSiteSettings();
description: 'Annuaire Afrohub', return {
}; title: `${settings.siteName} - L'Annuaire 2.0`,
description: settings.siteSlogan,
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) { export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await getSiteSettings(); const settings = await getSiteSettings();
@@ -30,7 +33,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<UserProvider> <UserProvider>
<Toaster position="top-right" /> <Toaster position="top-right" />
<AnalyticsTracker /> <AnalyticsTracker />
<Navbar /> <Navbar settings={settings} />
<main className="flex-grow"> <main className="flex-grow">
{children} {children}
</main> </main>

View File

@@ -7,7 +7,8 @@ import Link from 'next/link';
import { useUser } from '../../components/UserProvider'; import { useUser } from '../../components/UserProvider';
const LoginPage = () => { const LoginPage = () => {
const { login } = useUser(); const { settings, login } = useUser();
const siteName = settings?.siteName || "Afrohub";
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -46,7 +47,9 @@ const LoginPage = () => {
<div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-[80vh] flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
<div className="text-center"> <div className="text-center">
<div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">A</div> <div className="mx-auto h-12 w-12 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
{siteName.charAt(0)}
</div>
<h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Connexion à votre espace</h2> <h2 className="mt-6 text-3xl font-extrabold text-gray-900 font-serif">Connexion à votre espace</h2>
<p className="mt-2 text-sm text-gray-600"> <p className="mt-2 text-sm text-gray-600">
Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</Link> Ou <Link href="/register" className="font-medium text-brand-600 hover:text-brand-500">créez votre compte entreprise pour 1</Link>

View File

@@ -6,7 +6,9 @@ import Link from 'next/link';
import { User, Mail, Lock, ArrowRight, CheckCircle, AlertCircle } from 'lucide-react'; import { User, Mail, Lock, ArrowRight, CheckCircle, AlertCircle } from 'lucide-react';
const RegisterPage = () => { const RegisterPage = () => {
const { settings } = useUser();
const router = useRouter(); const router = useRouter();
const siteName = settings?.siteName || "Afrohub";
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
email: '', email: '',
@@ -67,7 +69,7 @@ const RegisterPage = () => {
<div className="max-w-md w-full"> <div className="max-w-md w-full">
<div className="text-center mb-10"> <div className="text-center mb-10">
<Link href="/" className="inline-flex items-center justify-center h-16 w-16 bg-brand-600 rounded-2xl shadow-lg shadow-brand-200 text-white font-bold text-3xl mb-6 hover:scale-105 transition-transform"> <Link href="/" className="inline-flex items-center justify-center h-16 w-16 bg-brand-600 rounded-2xl shadow-lg shadow-brand-200 text-white font-bold text-3xl mb-6 hover:scale-105 transition-transform">
A {siteName.charAt(0)}
</Link> </Link>
<h2 className="text-4xl font-extrabold text-dark-900 font-serif mb-2">Rejoignez l'aventure</h2> <h2 className="text-4xl font-extrabold text-dark-900 font-serif mb-2">Rejoignez l'aventure</h2>
<p className="text-gray-600">Créez votre compte pour propulser votre entreprise</p> <p className="text-gray-600">Créez votre compte pour propulser votre entreprise</p>
@@ -193,7 +195,7 @@ const RegisterPage = () => {
</div> </div>
<p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold"> <p className="mt-8 text-center text-xs text-gray-400 uppercase tracking-widest font-semibold">
Afrohub &copy; 2026 {siteName} &copy; 2026
</p> </p>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,118 @@
import React from 'react';
import { Calendar, MapPin, ArrowRight, Clock } from 'lucide-react';
import Link from 'next/link';
interface Event {
id: string;
slug?: string | null;
title: string;
description: string;
date: Date | string;
location: string;
thumbnailUrl: string;
link?: string | null;
}
interface EventTimelineProps {
events: Event[];
}
const EventTimeline = ({ events }: EventTimelineProps) => {
// Sort events by date descending
const sortedEvents = [...events].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
return (
<div className="relative max-w-5xl mx-auto px-4 py-12">
{/* Vertical Line */}
<div className="absolute left-4 md:left-1/2 top-0 bottom-0 w-1 bg-gradient-to-b from-brand-500 via-brand-600 to-amber-500 transform md:-translate-x-1/2 rounded-full opacity-20"></div>
<div className="space-y-12">
{sortedEvents.map((event, index) => {
const eventDate = new Date(event.date);
const isEven = index % 2 === 0;
return (
<div key={event.id} className={`relative flex flex-col md:flex-row items-center ${isEven ? 'md:flex-row-reverse' : ''}`}>
{/* Marker */}
<div className="absolute left-4 md:left-1/2 w-4 h-4 bg-white border-4 border-brand-600 rounded-full transform md:-translate-x-1/2 z-10 shadow-[0_0_15px_rgba(234,88,12,0.5)]"></div>
{/* Content Card */}
<div className={`w-full md:w-[45%] ml-12 md:ml-0 group`}>
<div className="relative bg-white/80 backdrop-blur-xl p-6 rounded-3xl border border-white shadow-[0_8px_30px_rgb(0,0,0,0.04)] hover:shadow-[0_8px_30px_rgb(0,0,0,0.12)] transition-all duration-500 group-hover:-translate-y-1">
{/* Image with overlay gradient */}
<div className="relative h-48 mb-6 rounded-2xl overflow-hidden">
<img
src={event.thumbnailUrl}
alt={event.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent"></div>
<div className="absolute bottom-4 left-4 right-4 flex justify-between items-end">
<span className="bg-brand-600/90 backdrop-blur-md text-white px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider flex items-center">
<Calendar className="w-3 h-3 mr-1" />
{eventDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
</span>
</div>
</div>
{/* Event Details */}
<div className="space-y-3">
<div className="flex items-center text-brand-600 text-xs font-bold uppercase tracking-widest">
<Clock className="w-3 h-3 mr-1" />
{eventDate.toLocaleDateString('fr-FR', { year: 'numeric' })}
</div>
<h3 className="text-2xl font-serif font-bold text-gray-900 group-hover:text-brand-600 transition-colors leading-tight">
{event.title}
</h3>
<div className="flex items-center text-sm text-gray-500">
<MapPin className="w-4 h-4 mr-1 text-gray-400" />
{event.location}
</div>
<div
className="text-gray-600 text-sm line-clamp-2 prose-sm"
dangerouslySetInnerHTML={{ __html: event.description }}
/>
<div className="pt-4 flex items-center justify-between">
<Link
href={`/afrolife/${event.slug || event.id}`}
className="inline-flex items-center text-brand-600 text-sm font-bold hover:underline group/link"
>
Voir les détails
<ArrowRight className="w-4 h-4 ml-1 transform group-hover/link:translate-x-1 transition-transform" />
</Link>
{/* Decorative element */}
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gray-100 ml-4"></div>
</div>
</div>
{/* Hover glow effect */}
<div className="absolute -inset-px bg-gradient-to-br from-brand-500/10 to-amber-500/10 rounded-3xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
</div>
</div>
{/* Date Column (Desktop Only) */}
<div className="hidden md:flex w-[45%] justify-center items-center px-8">
<div className={`text-center ${isEven ? 'text-right' : 'text-left'}`}>
<span className="block text-4xl font-serif font-bold text-gray-200 group-hover:text-brand-500/20 transition-colors">
{eventDate.toLocaleDateString('fr-FR', { month: 'long' })}
</span>
<span className="block text-xl font-bold text-gray-400">
{eventDate.getFullYear()}
</span>
</div>
</div>
</div>
);
})}
</div>
{/* Decorative End Dot */}
<div className="absolute left-4 md:left-1/2 bottom-0 w-4 h-4 bg-amber-500 rounded-full transform md:-translate-x-1/2 border-4 border-white shadow-lg"></div>
</div>
);
};
export default EventTimeline;

View File

@@ -6,11 +6,17 @@ import { usePathname } from 'next/navigation';
import { Menu, X, LayoutDashboard, User as UserIcon } from 'lucide-react'; import { Menu, X, LayoutDashboard, User as UserIcon } from 'lucide-react';
import { useUser } from '../components/UserProvider'; import { useUser } from '../components/UserProvider';
const Navbar = () => { interface NavbarProps {
settings?: any;
}
const Navbar = ({ settings }: NavbarProps) => {
const { user } = useUser(); const { user } = useUser();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname() || ''; const pathname = usePathname() || '';
const siteName = settings?.siteName || "Afrohub";
// Hide Navbar on Dashboard to prevent double navigation, show only on public pages // Hide Navbar on Dashboard to prevent double navigation, show only on public pages
if (pathname.startsWith('/dashboard')) return null; if (pathname.startsWith('/dashboard')) return null;
@@ -20,8 +26,10 @@ const Navbar = () => {
<div className="flex justify-between h-16"> <div className="flex justify-between h-16">
<div className="flex items-center"> <div className="flex items-center">
<Link href="/" className="flex-shrink-0 flex items-center gap-2"> <Link href="/" className="flex-shrink-0 flex items-center gap-2">
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">A</div> <div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold font-serif">
<span className="font-serif font-bold text-xl text-gray-900">Afrohub</span> {siteName.charAt(0)}
</div>
<span className="font-serif font-bold text-xl text-gray-900">{siteName}</span>
</Link> </Link>
<div className="hidden sm:ml-8 sm:flex sm:space-x-8"> <div className="hidden sm:ml-8 sm:flex sm:space-x-8">
<Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}> <Link href="/" className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${pathname === '/' ? 'border-brand-500 text-gray-900' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>

View File

@@ -6,6 +6,7 @@ import { useRouter, usePathname } from 'next/navigation';
interface UserContextType { interface UserContextType {
user: User | null; user: User | null;
settings: any | null;
login: (user: User) => void; login: (user: User) => void;
logout: () => void; logout: () => void;
} }
@@ -14,6 +15,7 @@ const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) { export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [settings, setSettings] = useState<any>(null);
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@@ -22,6 +24,12 @@ export function UserProvider({ children }: { children: ReactNode }) {
if (savedUser) { if (savedUser) {
setUser(JSON.parse(savedUser)); setUser(JSON.parse(savedUser));
} }
// Fetch settings
fetch('/api/settings')
.then(res => res.json())
.then(data => setSettings(data))
.catch(err => console.error('Error fetching settings:', err));
}, []); }, []);
// Global suspension check // Global suspension check
@@ -42,7 +50,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
}; };
return ( return (
<UserContext.Provider value={{ user, login, logout }}> <UserContext.Provider value={{ user, settings, login, logout }}>
{children} {children}
</UserContext.Provider> </UserContext.Provider>
); );

View File

@@ -115,32 +115,40 @@ model Offer {
} }
model BlogPost { model BlogPost {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
excerpt String slug String? @unique
content String excerpt String
author String content String
date DateTime @default(now()) author String
imageUrl String date DateTime @default(now())
createdAt DateTime @default(now()) imageUrl String
updatedAt DateTime @updatedAt tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Interview { model Interview {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
guestName String slug String? @unique
companyName String guestName String
role String companyName String
type InterviewType role String
thumbnailUrl String type InterviewType
videoUrl String? thumbnailUrl String
content String? videoUrl String?
excerpt String content String?
date DateTime @default(now()) excerpt String
duration String? date DateTime @default(now())
createdAt DateTime @default(now()) duration String?
updatedAt DateTime @updatedAt tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model Comment { model Comment {
@@ -288,3 +296,19 @@ model LegalDocument {
content String @db.Text content String @db.Text
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Event {
id String @id @default(uuid())
title String
slug String? @unique
description String @db.Text
date DateTime
location String
thumbnailUrl String
link String?
tags String[] @default([])
metaTitle String?
metaDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

15
scratch/check_featured.js Normal file
View File

@@ -0,0 +1,15 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function main() {
const featured = await prisma.business.findMany({
where: { isFeatured: true },
select: { id: true, name: true }
});
console.log('Featured businesses:', featured);
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect());

View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const featured = await prisma.business.findMany({
where: { isFeatured: true },
select: { id: true, name: true }
});
console.log('Featured businesses:', featured);
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect());