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,58 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function createBlogPost(data: {
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
}) {
try {
const post = await prisma.blogPost.create({
data: {
...data,
},
});
revalidatePath("/blog");
return { success: true, data: post };
} catch (error) {
console.error("Failed to create blog post:", error);
return { success: false, error: "Erreur lors de la création de l'article" };
}
}
export async function updateBlogPost(id: string, data: {
title: string;
excerpt: string;
content: string;
author: string;
imageUrl: string;
}) {
try {
const post = await prisma.blogPost.update({
where: { id },
data,
});
revalidatePath("/blog");
return { success: true, data: post };
} catch (error) {
console.error("Failed to update blog post:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteBlogPost(id: string) {
try {
await prisma.blogPost.delete({
where: { id },
});
revalidatePath("/blog");
return { success: true };
} catch (error) {
console.error("Failed to delete blog post:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,63 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function toggleVerification(id: string, currentStatus: boolean) {
try {
await prisma.business.update({
where: { id },
data: { verified: !currentStatus },
});
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to toggle verification:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function setFeaturedBusiness(id: string, data: {
founderName: string;
founderImageUrl: string;
keyMetric: string;
}) {
try {
// 1. Reset all others (we only want one entrepreneur of the month)
await prisma.business.updateMany({
where: { isFeatured: true },
data: { isFeatured: false },
});
// 2. Set the new one
await prisma.business.update({
where: { id },
data: {
isFeatured: true,
...data,
},
});
revalidatePath("/entrepreneurs");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to set featured business:", error);
return { success: false, error: "Erreur lors de la mise à jour de l'entrepreneur du mois" };
}
}
export async function removeFeaturedBusiness(id: string) {
try {
await prisma.business.update({
where: { id },
data: { isFeatured: false },
});
revalidatePath("/entrepreneurs");
return { success: true };
} catch (error) {
console.error("Failed to remove featured status:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,18 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteComment(id: string) {
try {
await prisma.comment.delete({
where: { id },
});
revalidatePath("/comments");
revalidatePath("/dashboard");
return { success: true };
} catch (error) {
console.error("Failed to delete comment:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,67 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { InterviewType } from "@prisma/client";
export async function createInterview(data: {
title: string;
guestName: string;
companyName: string;
role: string;
type: InterviewType;
thumbnailUrl: string;
videoUrl?: string;
content?: string;
excerpt: string;
duration?: string;
}) {
try {
const interview = await prisma.interview.create({
data,
});
revalidatePath("/blog");
return { success: true, data: interview };
} catch (error) {
console.error("Failed to create interview:", error);
return { success: false, error: "Erreur lors de la création de l'interview" };
}
}
export async function updateInterview(id: string, data: {
title: string;
guestName: string;
companyName: string;
role: string;
type: InterviewType;
thumbnailUrl: string;
videoUrl?: string;
content?: string;
excerpt: string;
duration?: string;
}) {
try {
const interview = await prisma.interview.update({
where: { id },
data,
});
revalidatePath("/blog");
return { success: true, data: interview };
} catch (error) {
console.error("Failed to update interview:", error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function deleteInterview(id: string) {
try {
await prisma.interview.delete({
where: { id },
});
revalidatePath("/blog");
return { success: true };
} catch (error) {
console.error("Failed to delete interview:", error);
return { success: false, error: "Erreur lors de la suppression" };
}
}

View File

@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import BlogForm from "@/components/BlogForm";
import { notFound } from "next/navigation";
export default async function EditBlogPage({ params }: { params: { id: string } }) {
const { id } = params;
const post = await prisma.blogPost.findUnique({
where: { id },
});
if (!post) {
notFound();
}
return <BlogForm initialData={post} />;
}

View File

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

125
admin/src/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,125 @@
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import { Plus, BookOpen, Mic2, Trash2, Edit } from 'lucide-react';
import DeleteBlogButton from '@/components/DeleteBlogButton';
import DeleteInterviewButton from '@/components/DeleteInterviewButton';
async function getData() {
const posts = await prisma.blogPost.findMany({ orderBy: { createdAt: 'desc' } });
const interviews = await prisma.interview.findMany({ orderBy: { createdAt: 'desc' } });
return { posts, interviews };
}
export default async function BlogCMSPage() {
const { posts, interviews } = await getData();
return (
<div>
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">CMS Blog & Interviews</h1>
<p className="text-slate-400">Gérez le contenu éditorial de la plateforme.</p>
</div>
<div className="flex gap-4">
<Link href="/blog/new" className="btn-verify flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvel Article
</Link>
<Link href="/interview/new" className="btn-verify bg-indigo-600 flex items-center gap-2">
<Plus className="w-4 h-4" />
Nouvelle Interview
</Link>
</div>
</div>
<div className="grid grid-cols-1 gap-8">
{/* Blog Posts Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<BookOpen className="text-indigo-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Articles de Blog ({posts.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Titre</th>
<th>Auteur</th>
<th>Date</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td>
<div className="font-medium text-white">{post.title}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{post.excerpt}</div>
</td>
<td className="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="text-right">
<div className="flex justify-end gap-2">
<Link href={`/blog/edit/${post.id}`} className="p-2 text-slate-400 hover:text-indigo-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteBlogButton id={post.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Interviews Section */}
<div className="card">
<div className="flex items-center gap-2 mb-6">
<Mic2 className="text-emerald-400 w-5 h-5" />
<h2 className="text-xl font-bold text-white">Interviews ({interviews.length})</h2>
</div>
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Interviewé</th>
<th>Entreprise / Rôle</th>
<th>Type</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{interviews.map((interview) => (
<tr key={interview.id}>
<td>
<div className="font-medium text-white">{interview.guestName}</div>
<div className="text-xs text-slate-500 truncate max-w-xs">{interview.title}</div>
</td>
<td>
<div className="text-sm text-slate-300">{interview.companyName}</div>
<div className="text-xs text-slate-500">{interview.role}</div>
</td>
<td>
<span className={`badge ${interview.type === 'VIDEO' ? 'badge-verified' : 'badge-pending'}`}>
{interview.type}
</span>
</td>
<td className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/interview/edit/${interview.id}`} className="p-2 text-slate-400 hover:text-emerald-400">
<Edit className="w-5 h-5" />
</Link>
<DeleteInterviewButton id={interview.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { prisma } from '@/lib/prisma';
import DeleteCommentButton from '@/components/DeleteCommentButton';
async function getComments() {
return await prisma.comment.findMany({
include: {
author: true,
business: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function CommentsPage() {
const comments = await getComments();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Modération des Commentaires</h1>
<p className="text-slate-400">Surveiller et supprimer les commentaires inappropriés.</p>
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Auteur</th>
<th>Entreprise</th>
<th>Contenu</th>
<th>Date</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{comments.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-slate-500">
Aucun commentaire trouvé.
</td>
</tr>
) : (
comments.map((comment) => (
<tr key={comment.id}>
<td>
<div className="font-medium text-white">{comment.author.name}</div>
<div className="text-xs text-slate-500">{comment.author.email}</div>
</td>
<td>
<div className="text-sm text-indigo-400">{comment.business.name}</div>
</td>
<td className="max-w-md">
<p className="text-sm text-slate-300 truncate" title={comment.content}>
{comment.content}
</p>
</td>
<td>
<div className="text-sm text-slate-500">
{new Date(comment.createdAt).toLocaleDateString('fr-FR')}
</div>
</td>
<td className="text-right">
<DeleteCommentButton id={comment.id} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { prisma } from '@/lib/prisma';
import {
Users,
Store,
Clock,
MessageCircle,
Eye
} from 'lucide-react';
async function getStats() {
const usersCount = await prisma.user.count();
const businessCount = await prisma.business.count();
const pendingCount = await prisma.business.count({ where: { verified: false } });
const commentsCount = await prisma.comment.count();
// Aggregate views
const aggregateResult = await prisma.business.aggregate({
_sum: {
viewCount: true
}
});
const totalViews = aggregateResult._sum.viewCount || 0;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews };
}
export default async function DashboardPage() {
const stats = await getStats();
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },
{ label: 'Total Vues', value: stats.totalViews, icon: Eye, color: 'text-brand-400' },
];
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Tableau de bord</h1>
<p className="text-slate-400">Vue d'ensemble de l'activité sur la plateforme.</p>
</div>
<div className="stats-grid">
{statCards.map((card) => (
<div key={card.label} className="card">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-lg bg-slate-800 ${card.color}`}>
<card.icon className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{card.value}</span>
</div>
<h3 className="text-slate-400 font-medium">{card.label}</h3>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
<div className="card">
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
<div className="space-y-4">
<p className="text-slate-500 italic">Bientôt disponible...</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { prisma } from '@/lib/prisma';
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
async function getBusinesses() {
return await prisma.business.findMany({
include: {
owner: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
export default async function EntrepreneursPage() {
const businesses = await getBusinesses();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
<p className="text-slate-400">Valider ou révoquer les comptes des entreprises.</p>
</div>
<div className="card overflow-hidden p-0">
<table className="data-table">
<thead>
<tr>
<th>Entreprise</th>
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Statut</th>
<th className="text-center">Vues</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business) => (
<tr key={business.id}>
<td>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
{business.logoUrl ? (
<img src={business.logoUrl} alt={business.name} className="w-full h-full object-cover" />
) : (
<span className="text-xs text-slate-500">LOGO</span>
)}
</div>
<div>
<div className="font-semibold text-white">{business.name}</div>
<div className="text-xs text-slate-500">{business.location}</div>
</div>
</div>
</td>
<td>
<span className="text-sm text-slate-300">{business.category}</span>
</td>
<td>
<div className="text-sm text-white">{business.owner.name}</div>
<div className="text-xs text-slate-500">{business.owner.email}</div>
</td>
<td>
{business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</td>
<td className="text-center font-medium text-slate-300">
{business.viewCount.toLocaleString()}
</td>
<td className="text-center">
<FeaturedModal business={business} />
</td>
<td className="text-right">
<ToggleVerifyButton id={business.id} verified={business.verified} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

BIN
admin/src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

195
admin/src/app/globals.css Normal file
View File

@@ -0,0 +1,195 @@
@import "tailwindcss";
:root {
--background: #0f172a;
--foreground: #f8fafc;
--primary: #6366f1;
--secondary: #1e293b;
--accent: #10b981;
--muted: #64748b;
--border: #334155;
--card: #1e293b;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: 'Inter', sans-serif;
margin: 0;
padding: 0;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--border);
}
.admin-sidebar {
width: 260px;
background-color: var(--card);
border-right: 1px solid var(--border);
height: 100vh;
position: fixed;
left: 0;
top: 0;
}
.admin-content {
margin-left: 260px;
padding: 2rem;
min-height: 100vh;
}
.nav-link {
display: flex;
align-items: center;
padding: 0.75rem 1.5rem;
color: var(--muted);
text-decoration: none;
transition: all 0.2s;
border-radius: 0.5rem;
margin: 0.25rem 1rem;
}
.nav-link:hover, .nav-link.active {
background-color: var(--secondary);
color: var(--foreground);
}
.nav-link.active {
color: var(--primary);
background-color: rgba(99, 102, 241, 0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.data-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.data-table th {
text-align: left;
padding: 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
}
.data-table td {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.btn-verify {
background-color: var(--accent);
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-verify:hover {
opacity: 0.9;
}
.btn-unverify {
background-color: #ef4444;
color: white;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.btn-unverify:hover {
opacity: 0.9;
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-verified {
background-color: rgba(16, 185, 129, 0.1);
color: var(--accent);
}
.badge-pending {
background-color: rgba(245, 158, 11, 0.1);
color: #f59e0b;
}
/* Modals */
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal-content {
background-color: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
width: 100%;
max-width: 500px;
padding: 2rem;
position: relative;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.5);
}
.btn-star {
color: var(--muted);
transition: color 0.2s;
}
.btn-star:hover, .btn-star.active {
color: #f59e0b;
}

View File

@@ -0,0 +1,16 @@
import { prisma } from "@/lib/prisma";
import InterviewForm from "@/components/InterviewForm";
import { notFound } from "next/navigation";
export default async function EditInterviewPage({ params }: { params: { id: string } }) {
const { id } = params;
const interview = await prisma.interview.findUnique({
where: { id },
});
if (!interview) {
notFound();
}
return <InterviewForm initialData={interview} />;
}

View File

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

30
admin/src/app/layout.tsx Normal file
View File

@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Sidebar from "@/components/Sidebar";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AfroAdmin - Administration Afropreunariat",
description: "Panneau d'administration pour la plateforme Afropreunariat",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className={inter.className}>
<div className="flex min-h-screen">
<Sidebar />
<main className="admin-content flex-1">
{children}
</main>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,205 @@
import { prisma } from '@/lib/prisma';
import {
BarChart3,
MousePointer2,
Clock,
Eye,
ArrowUpRight,
TrendingUp
} from 'lucide-react';
async function getMetrics() {
// 1. Top Pages
const topPages = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'PAGE_VIEW' },
_count: {
id: true
},
orderBy: {
_count: {
id: 'desc'
}
},
take: 10
});
// 2. Top Clicks
const topClicks = await prisma.analyticsEvent.groupBy({
by: ['label'],
where: { type: 'CLICK' },
_count: {
id: true
},
orderBy: {
_count: {
id: 'desc'
}
},
take: 10
});
// 3. Average Dwell Time
const dwellTime = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'DWELL_TIME' },
_avg: {
value: true
},
_count: {
id: true
},
orderBy: {
_avg: {
value: 'desc'
}
},
take: 10
});
// 4. Global Stats
const totalEvents = await prisma.analyticsEvent.count();
const uniquePaths = (await prisma.analyticsEvent.groupBy({ by: ['path'] })).length;
return { topPages, topClicks, dwellTime, totalEvents, uniquePaths };
}
export default async function MetricsPage() {
const { topPages, topClicks, dwellTime, totalEvents, uniquePaths } = await getMetrics();
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
<p className="text-slate-400">Comportement des utilisateurs et engagement en temps réel.</p>
</div>
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Eye className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{uniquePaths}</span>
</div>
<h3 className="text-slate-400 font-medium">Pages uniques explorées</h3>
</div>
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-pink-500/10 text-pink-400">
<MousePointer2 className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">
{topClicks.reduce((acc, curr) => acc + curr._count.id, 0)}
</span>
</div>
<h3 className="text-slate-400 font-medium">Clicks enregistrés (Top 10)</h3>
</div>
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">
{Math.round(dwellTime.reduce((acc, curr) => acc + (curr._avg.value || 0), 0) / (dwellTime.length || 1))}s
</span>
</div>
<h3 className="text-slate-400 font-medium">Temps moyen / page</h3>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Pages Table */}
<div className="card p-0 overflow-hidden">
<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">
<TrendingUp className="w-5 h-5 text-emerald-400" /> Pages les plus visitées
</h2>
</div>
<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">URL / Chemin</th>
<th className="px-6 py-4 text-right">Vues</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topPages.map((page) => (
<tr key={page.path} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-slate-300">{page.path}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{page._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Top Clicks Table */}
<div className="card p-0 overflow-hidden">
<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">
<MousePointer2 className="w-5 h-5 text-indigo-400" /> Actions & Clicks
</h2>
</div>
<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">Élément / Texte</th>
<th className="px-6 py-4 text-right">Interactions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topClicks.map((click) => (
<tr key={click.label} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 truncate max-w-xs">{click.label || 'Sans label'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{click._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Dwell Time 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">
<Clock className="w-5 h-5 text-amber-400" /> Temps d'attention par page
</h2>
</div>
<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">Page</th>
<th className="px-6 py-4">Échantillon</th>
<th className="px-6 py-4 text-right">Moyenne de rétention</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{dwellTime.map((time) => (
<tr key={time.path} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-slate-300">{time.path}</td>
<td className="px-6 py-4 text-xs text-slate-500">{time._count.id} sessions</td>
<td className="px-6 py-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
{Math.round(time._avg.value || 0)} secondes
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

5
admin/src/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

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

20
admin/src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
// Updated: 2026-04-10
import pg from 'pg'
const connectionString = process.env.DATABASE_URL!
function makePrisma() {
const pool = new pg.Pool({ connectionString })
const adapter = new PrismaPg(pool)
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma || makePrisma()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma