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

41
admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
admin/AGENTS.md Normal file
View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
admin/CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

36
admin/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
admin/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
admin/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7845
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
admin/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"@prisma/config": "^7.7.0",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.3",
"dotenv": "^17.4.1",
"lucide-react": "^1.8.0",
"next": "16.2.3",
"pg": "^8.20.0",
"prisma": "^7.7.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv-cli": "^11.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
admin/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

13
admin/prisma.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import path from 'node:path'
import { config as loadEnv } from 'dotenv'
import { defineConfig, env } from '@prisma/config'
// Load .env
loadEnv({ path: '.env' })
export default defineConfig({
schema: path.join('prisma', 'schema.prisma'),
datasource: {
url: env('DATABASE_URL'),
},
})

147
admin/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,147 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum UserRole {
VISITOR
ENTREPRENEUR
ADMIN
}
model User {
id String @id @default(uuid())
name String
email String @unique
password String
role UserRole @default(VISITOR)
avatar String?
businesses Business[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Business {
id String @id @default(uuid())
name String
category String
location String
description String @db.Text
logoUrl String
videoUrl String?
// Contact & Social
contactEmail String
contactPhone String?
// We use JSON to store social links (facebook, linkedin, instagram, website)
socialLinks Json?
// Stats
verified Boolean @default(false)
viewCount Int @default(0)
rating Float @default(0.0)
tags String[]
// Entrepreneur of the Month fields
isFeatured Boolean @default(false)
founderName String?
founderImageUrl String?
keyMetric String?
// Relations
ownerId String @unique
owner User @relation(fields: [ownerId], references: [id])
offers Offer[]
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum OfferType {
PRODUCT
SERVICE
}
model Offer {
id String @id @default(uuid())
title String
type OfferType
price Float
currency String @default("XOF") // EUR or XOF
description String? @db.Text
imageUrl String
active Boolean @default(true)
// Relations
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model BlogPost {
id String @id @default(uuid())
title String
excerpt String @db.Text
content String @db.Text
author String
date DateTime @default(now())
imageUrl String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum InterviewType {
VIDEO
ARTICLE
}
model Interview {
id String @id @default(uuid())
title String
guestName String
companyName String
role String
type InterviewType
thumbnailUrl String
videoUrl String?
content String? @db.Text
excerpt String @db.Text
date DateTime @default(now())
duration String? // "15 min" ou "5 min de lecture"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(uuid())
content String @db.Text
// Relations
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
businessId String
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AnalyticsEvent {
id String @id @default(uuid())
type String // PAGE_VIEW, CLICK, DWELL_TIME
path String
label String? // For clicks (button text, link name)
value Float? // For dwell time (seconds)
metadata Json? // Browser, OS, etc.
createdAt DateTime @default(now())
}

1
admin/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
admin/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
admin/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
admin/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
admin/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

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

26
admin/test-db.js Normal file
View File

@@ -0,0 +1,26 @@
const { Pool } = require('pg');
require('dotenv').config();
async function test() {
const connectionString = process.env.DATABASE_URL;
console.log('Testing connection to:', connectionString ? 'URL present' : 'URL MISSING');
const pool = new Pool({
connectionString,
connectionTimeoutMillis: 5000,
});
try {
const client = await pool.connect();
console.log('Successfully connected to DB!');
const res = await client.query('SELECT NOW()');
console.log('Query result:', res.rows[0]);
client.release();
} catch (err) {
console.error('Connection failed:', err.message);
} finally {
await pool.end();
}
}
test();

34
admin/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}