1. Système d'Analytics (Nouveauté Majeure)

Modèle de Données : Ajout de la table AnalyticsEvent dans prisma/schema.prisma pour suivre les vues de pages, les clics et le temps de rétention (DWELL_TIME).
API Backend : Création de la route app/api/analytics/route.ts pour enregistrer les événements en base de données.
Tracking Client : Création du composant AnalyticsTracker.tsx (intégré dans le layout principal) qui capture automatiquement :
Le changement de page (Page Views).
Les clics sur les boutons et liens importants.
Le temps passé sur chaque page.
2. Intégration Base de Données (PostgreSQL/Prisma)
Fiches Entreprises : Mise à jour de app/directory/[id]/page.tsx pour récupérer les données réelles depuis PostgreSQL via Prisma au lieu d'utiliser les données de test (mockData).
Navigation Dashboard : Correction du bouton "Voir ma fiche" dans le tableau de bord pour qu'il redirige correctement vers le profil public de l'entrepreneur.
3. Administration & Metrics
Nouvelle Application Admin : Initialisation d'un dossier admin/ (application Next.js séparée) destinée à la gestion de la plateforme et à la visualisation des statistiques (Metrics & Analytics).
4. Authentification & Inscription
Nouvelle Page : Création de app/register/page.tsx pour permettre l'inscription des nouveaux utilisateurs.
Gestion de Session : Amélioration de UserProvider.tsx pour une meilleure gestion de l'état utilisateur à travers l'application.
5. Maintenance & Types
Mise à jour des types globaux dans types.ts et rafraîchissement du package-lock.json suite à l'ajout de dépendances.
This commit is contained in:
2026-04-11 22:20:41 +02:00
parent d88ba7c53c
commit b227657f19
100 changed files with 14142 additions and 157 deletions

View File

@@ -0,0 +1,138 @@
"use client";
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { createBlogPost, updateBlogPost } from '@/app/actions/blog';
import { Loader2, ArrowLeft, Save } from 'lucide-react';
import Link from 'next/link';
interface Props {
initialData?: {
id: string;
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
};
}
export default function BlogForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
title: formData.get('title') as string,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string,
author: formData.get('author') as string,
imageUrl: formData.get('imageUrl') as string,
};
startTransition(async () => {
const result = initialData
? await updateBlogPost(initialData.id, data)
: await createBlogPost(data);
if (result.success) {
router.push('/blog');
router.refresh();
} else {
alert(result.error);
}
});
};
return (
<div className="max-w-4xl mx-auto">
<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\'article' : 'Nouvel Article'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="card space-y-6">
<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">
<label className="text-sm font-medium text-slate-400">URL de l'image</label>
<input
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">
<label className="text-sm font-medium text-slate-400">Extrait (Excerpt)</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"
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>
<textarea
name="content"
defaultValue={initialData?.content}
required
rows={12}
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-indigo-500 font-serif leading-relaxed"
placeholder="Rédigez votre article ici..."
/>
</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"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Publier l\'article'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useTransition } from 'react';
import { deleteBlogPost } from '@/app/actions/blog';
import { Trash2, Loader2 } from 'lucide-react';
export default function DeleteBlogButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cet article ?")) {
startTransition(async () => {
const result = await deleteBlogPost(id);
if (!result.success) alert(result.error);
});
}
};
return (
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { useTransition } from 'react';
import { deleteComment } from '@/app/actions/comment';
import { Trash2, Loader2 } from 'lucide-react';
interface Props {
id: string;
}
export default function DeleteCommentButton({ id }: Props) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Êtes-vous sûr de vouloir supprimer ce commentaire ?")) {
startTransition(async () => {
const result = await deleteComment(id);
if (!result.success) {
alert(result.error);
}
});
}
};
return (
<button
onClick={handleDelete}
disabled={isPending}
className="p-2 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
title="Supprimer"
>
{isPending ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Trash2 className="w-5 h-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useTransition } from 'react';
import { deleteInterview } from '@/app/actions/interview';
import { Trash2, Loader2 } from 'lucide-react';
export default function DeleteInterviewButton({ id }: { id: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
if (confirm("Supprimer cette interview ?")) {
startTransition(async () => {
const result = await deleteInterview(id);
if (!result.success) alert(result.error);
});
}
};
return (
<button onClick={handleDelete} disabled={isPending} className="p-2 text-slate-500 hover:text-red-400">
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Trash2 className="w-5 h-5" />}
</button>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useState, useTransition } from 'react';
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
import { Star, X, Loader2, Save } from 'lucide-react';
interface Props {
business: {
id: string;
name: string;
isFeatured: boolean;
founderName?: string | null;
founderImageUrl?: string | null;
keyMetric?: string | null;
};
}
export default function FeaturedModal({ business }: Props) {
const [isOpen, setIsOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data = {
founderName: formData.get('founderName') as string,
founderImageUrl: formData.get('founderImageUrl') as string,
keyMetric: formData.get('keyMetric') as string,
};
startTransition(async () => {
const result = await setFeaturedBusiness(business.id, data);
if (result.success) {
setIsOpen(false);
} else {
alert(result.error);
}
});
};
const handleRemove = () => {
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
startTransition(async () => {
const result = await removeFeaturedBusiness(business.id);
if (result.success) setIsOpen(false);
});
}
};
return (
<>
<button
onClick={() => setIsOpen(true)}
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
business.isFeatured
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
}`}
>
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
</button>
{isOpen && (
<div className="modal-overlay">
<div className="modal-content">
<button
onClick={() => setIsOpen(false)}
className="absolute top-4 right-4 text-slate-500 hover:text-white"
>
<X className="w-6 h-6" />
</button>
<div className="mb-6">
<h2 className="text-2xl font-bold text-white mb-2">💰 Entrepreneur du Mois</h2>
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
</div>
<form onSubmit={handleToggleFeature} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
<input
name="founderName"
defaultValue={business.founderName || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: Aliko Dangote"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
<input
name="founderImageUrl"
defaultValue={business.founderImageUrl || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="https://..."
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
<input
name="keyMetric"
defaultValue={business.keyMetric || ''}
required
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
placeholder="Ex: +500 emplois créés"
/>
</div>
<div className="pt-4 flex flex-col gap-3">
<button
type="submit"
disabled={isPending}
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
>
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Définir comme Entrepreneur du Mois
</button>
{business.isFeatured && (
<button
type="button"
onClick={handleRemove}
disabled={isPending}
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
>
Révoquer le titre
</button>
)}
</div>
</form>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { createInterview, updateInterview } from '@/app/actions/interview';
import { Loader2, ArrowLeft, Save, Video, FileText } from 'lucide-react';
import Link from 'next/link';
import { InterviewType } from '@prisma/client';
interface Props {
initialData?: any;
}
export default function InterviewForm({ initialData }: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const data: any = {
title: formData.get('title') as string,
guestName: formData.get('guestName') as string,
companyName: formData.get('companyName') as string,
role: formData.get('role') as string,
type: formData.get('type') as InterviewType,
thumbnailUrl: formData.get('thumbnailUrl') as string,
videoUrl: formData.get('videoUrl') as string || null,
excerpt: formData.get('excerpt') as string,
content: formData.get('content') as string || null,
duration: formData.get('duration') as string || null,
};
startTransition(async () => {
const result = initialData
? await updateInterview(initialData.id, data)
: await createInterview(data);
if (result.success) {
router.push('/blog');
router.refresh();
} else {
alert(result.error);
}
});
};
return (
<div className="max-w-4xl mx-auto">
<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\'interview' : 'Nouvelle Interview'}
</h1>
</div>
</div>
<form onSubmit={handleSubmit} className="card space-y-6">
<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">
<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>
<textarea
name="content"
defaultValue={initialData?.content}
rows={8}
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="pt-4">
<button
type="submit"
disabled={isPending}
className="w-full btn-verify flex items-center justify-center gap-2 py-4 text-lg bg-emerald-600"
>
{isPending ? (
<Loader2 className="w-6 h-6 animate-spin" />
) : (
<Save className="w-6 h-6" />
)}
{initialData ? 'Enregistrer les modifications' : 'Créer l\'interview'}
</button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
MessageSquare,
BookOpen,
LogOut,
ShieldCheck,
BarChart3
} from 'lucide-react';
const menuItems = [
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
];
export default function Sidebar() {
const pathname = usePathname();
return (
<div className="admin-sidebar p-6 flex flex-col">
<div className="flex items-center gap-3 mb-10 px-2">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
<ShieldCheck className="text-white" />
</div>
<h1 className="text-xl font-bold tracking-tight text-white">AfroAdmin</h1>
</div>
<nav className="flex-1 space-y-1">
{menuItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.name}
href={item.href}
className={`nav-link ${isActive ? 'active' : ''}`}
>
<item.icon className="w-5 h-5 mr-3" />
<span>{item.name}</span>
</Link>
);
})}
</nav>
<div className="mt-auto pt-6 border-t border-slate-700">
<button className="nav-link w-full text-left text-red-400 hover:bg-red-950/30">
<LogOut className="w-5 h-5 mr-3" />
<span>Déconnexion</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { useTransition } from 'react';
import { toggleVerification } from '@/app/actions/business';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
interface Props {
id: string;
verified: boolean;
}
export default function ToggleVerifyButton({ id, verified }: Props) {
const [isPending, startTransition] = useTransition();
const handleToggle = () => {
startTransition(async () => {
const result = await toggleVerification(id, verified);
if (!result.success) {
alert(result.error);
}
});
};
return (
<button
onClick={handleToggle}
disabled={isPending}
className={verified ? "btn-unverify" : "btn-verify"}
>
<div className="flex items-center gap-2">
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : verified ? (
<XCircle className="w-4 h-4" />
) : (
<CheckCircle className="w-4 h-4" />
)}
{verified ? "Révoquer" : "Valider"}
</div>
</button>
);
}