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:
58
admin/src/app/actions/blog.ts
Normal file
58
admin/src/app/actions/blog.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
63
admin/src/app/actions/business.ts
Normal file
63
admin/src/app/actions/business.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
18
admin/src/app/actions/comment.ts
Normal file
18
admin/src/app/actions/comment.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
67
admin/src/app/actions/interview.ts
Normal file
67
admin/src/app/actions/interview.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
16
admin/src/app/blog/edit/[id]/page.tsx
Normal file
16
admin/src/app/blog/edit/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
5
admin/src/app/blog/new/page.tsx
Normal file
5
admin/src/app/blog/new/page.tsx
Normal 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
125
admin/src/app/blog/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
admin/src/app/comments/page.tsx
Normal file
75
admin/src/app/comments/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
admin/src/app/dashboard/page.tsx
Normal file
75
admin/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
admin/src/app/entrepreneurs/page.tsx
Normal file
95
admin/src/app/entrepreneurs/page.tsx
Normal 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
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
195
admin/src/app/globals.css
Normal 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;
|
||||
}
|
||||
16
admin/src/app/interview/edit/[id]/page.tsx
Normal file
16
admin/src/app/interview/edit/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
5
admin/src/app/interview/new/page.tsx
Normal file
5
admin/src/app/interview/new/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import InterviewForm from "@/components/InterviewForm";
|
||||
|
||||
export default function NewInterviewPage() {
|
||||
return <InterviewForm />;
|
||||
}
|
||||
30
admin/src/app/layout.tsx
Normal file
30
admin/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
admin/src/app/metrics/page.tsx
Normal file
205
admin/src/app/metrics/page.tsx
Normal 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
5
admin/src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
Reference in New Issue
Block a user