synchronisation du contenu avec la DB et amélioration du CMS

- Migration du blog et des interviews des données mockées vers PostgreSQL (Prisma).
- Refonte des pages publiques en Server Components pour de meilleures performances/SEO.
- Intégration d'un éditeur de texte riche (React Quill) avec titres H1-H6 et séparateurs.
- Correction des erreurs de validation Prisma liées aux paramètres asynchrones (Next.js 15+).
- Correction des problèmes d'affichage des modales de suspension via React Portals.
- Installation de @tailwindcss/typography et correction du débordement de texte (break-words).
- Implémentation des actions de modération (suspension/révocation) pour les utilisateurs et boutiques.
This commit is contained in:
2026-04-12 18:59:20 +02:00
parent b73939d381
commit 887030ee47
84 changed files with 5577 additions and 15128 deletions

View File

@@ -0,0 +1,52 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getReports() {
return await prisma.messageReport.findMany({
include: {
message: {
include: {
sender: {
select: { id: true, name: true, email: true }
},
conversation: {
include: {
business: {
select: { id: true, name: true }
},
messages: {
orderBy: { createdAt: 'asc' },
include: {
sender: {
select: { id: true, name: true }
}
}
}
}
}
}
},
reporter: {
select: { id: true, name: true, email: true }
}
},
orderBy: {
createdAt: 'desc',
},
});
}
export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMISSED') {
try {
await prisma.messageReport.update({
where: { id },
data: { status }
});
revalidatePath('/moderation');
return { success: true };
} catch (error) {
return { success: false, error: "Erreur lors de la mise à jour" };
}
}

View File

@@ -0,0 +1,125 @@
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
/**
* Suspend a user account
*/
export async function suspendUser(userId: string, reason: string) {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
isSuspended: true,
suspensionReason: reason,
},
include: {
businesses: true
}
});
// If the user is an entrepreneur with a business, we also suspend the business by default
// to hide it from the site during the user's suspension.
if (user.businesses) {
await prisma.business.update({
where: { id: user.businesses.id },
data: {
isSuspended: true,
suspensionReason: `Compte propriétaire suspendu : ${reason}`,
},
});
}
revalidatePath("/clients");
revalidatePath("/entrepreneurs");
revalidatePath("/moderation");
return { success: true, data: user };
} catch (error) {
console.error("Failed to suspend user:", error);
return { success: false, error: "Erreur lors de la suspension" };
}
}
/**
* Unsuspend a user account
*/
export async function unsuspendUser(userId: string) {
try {
const user = await prisma.user.update({
where: { id: userId },
data: {
isSuspended: false,
suspensionReason: null,
},
include: {
businesses: true
}
});
// Also unsuspend their business if they had one
if (user.businesses) {
await prisma.business.update({
where: { id: user.businesses.id },
data: {
isSuspended: false,
suspensionReason: null,
},
});
}
revalidatePath("/clients");
revalidatePath("/entrepreneurs");
revalidatePath("/moderation");
return { success: true, data: user };
} catch (error) {
console.error("Failed to unsuspend user:", error);
return { success: false, error: "Erreur lors de la réactivation" };
}
}
/**
* Suspend a specific business (without suspending the whole user account)
*/
export async function suspendBusiness(businessId: string, reason: string) {
try {
const business = await prisma.business.update({
where: { id: businessId },
data: {
isSuspended: true,
suspensionReason: reason,
},
});
revalidatePath("/entrepreneurs");
return { success: true, data: business };
} catch (error) {
console.error("Failed to suspend business:", error);
return { success: false, error: "Erreur lors de la suspension de la boutique" };
}
}
/**
* Unsuspend a specific business
*/
export async function unsuspendBusiness(businessId: string) {
try {
const business = await prisma.business.update({
where: { id: businessId },
data: {
isSuspended: false,
suspensionReason: null,
},
});
revalidatePath("/entrepreneurs");
return { success: true, data: business };
} catch (error) {
console.error("Failed to unsuspend business:", error);
return { success: false, error: "Erreur lors de la réactivation de la boutique" };
}
}

View File

@@ -0,0 +1,27 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function getClients() {
try {
const clients = await prisma.user.findMany({
where: {
role: { not: "ADMIN" },
OR: [
{ businesses: null },
{ businesses: { isActive: false } }
]
},
include: {
businesses: true
},
orderBy: {
createdAt: "desc"
}
});
return { success: true, data: clients };
} catch (error) {
console.error("Failed to fetch clients:", error);
return { success: false, error: "Erreur lors de la récupération des clients" };
}
}

View File

@@ -2,8 +2,8 @@ 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;
export default async function EditBlogPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await prisma.blogPost.findUnique({
where: { id },
});

View File

@@ -0,0 +1,110 @@
import { getClients } from "@/app/actions/user";
import { User, Mail, Phone, MapPin } from "lucide-react";
import ClientActions from "@/components/ClientActions";
export default async function ClientsPage() {
const result = await getClients();
const clients = result.success ? (result.data || []) : [];
return (
<div className="space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Clients</h1>
<p className="text-slate-400">Liste des utilisateurs n'ayant pas encore de boutique active.</p>
</div>
<div className="bg-slate-800/50 px-4 py-2 rounded-lg border border-slate-700/50">
<span className="text-slate-400 text-sm">Total Clients: </span>
<span className="text-indigo-400 font-bold ml-1">{clients.length}</span>
</div>
</div>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="data-table">
<thead>
<tr>
<th>Utilisateur</th>
<th>Contact</th>
<th>Localisation</th>
<th>Statut</th>
<th className="text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clients.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center gap-2">
<User className="w-8 h-8 opacity-20" />
<p>Aucun client trouvé.</p>
</div>
</td>
</tr>
) : (
clients.map((client: any) => (
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
<td className="py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-sm overflow-hidden border border-slate-700">
{client.avatar ? (
<img src={client.avatar} alt={client.name} className="w-full h-full object-cover" />
) : (
client.name.charAt(0)
)}
</div>
<div>
<p className="text-white font-semibold">{client.name}</p>
<p className="text-slate-500 text-xs">ID: {client.id.split('-')[0]}...</p>
</div>
</div>
</td>
<td>
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Mail className="w-3 h-3 text-slate-500" />
{client.email}
</div>
{client.phone && (
<div className="flex items-center gap-1.5 text-xs text-slate-300">
<Phone className="w-3 h-3 text-slate-500" />
{client.phone}
</div>
)}
</div>
</td>
<td>
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<MapPin className="w-3 h-3" />
{client.location || "Non spécifié"}
</div>
</td>
<td>
<div className="flex flex-col gap-1">
{client.isSuspended ? (
<>
<span className="badge badge-error">Compte Suspendu</span>
<span className="text-[10px] text-rose-500/70 truncate max-w-[150px]" title={client.suspensionReason}>
{client.suspensionReason}
</span>
</>
) : client.businesses ? (
<span className="badge badge-pending">Shop Inactif</span>
) : (
<span className="badge bg-slate-800 text-slate-400">Actif</span>
)}
</div>
</td>
<td className="text-right">
<ClientActions userId={client.id} userName={client.name} isSuspended={!!client.isSuspended} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +1,14 @@
import { prisma } from '@/lib/prisma';
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
import FeaturedModal from '@/components/FeaturedModal';
import EntrepreneurActions from '@/components/EntrepreneurActions';
import { ShieldAlert, ShieldX } from 'lucide-react';
async function getBusinesses() {
return await prisma.business.findMany({
where: {
isActive: true
},
include: {
owner: true,
},
@@ -31,7 +36,7 @@ export default async function EntrepreneursPage() {
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Statut</th>
<th className="text-center">Vues</th>
<th className="text-center">Visibilité</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
</tr>
@@ -39,13 +44,13 @@ export default async function EntrepreneursPage() {
<tbody>
{businesses.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-10 text-slate-500">
<td colSpan={7} className="text-center py-10 text-slate-500">
Aucun entrepreneur trouvé.
</td>
</tr>
) : (
businesses.map((business) => (
<tr key={business.id}>
businesses.map((business: any) => (
<tr key={business.id} className={(business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}>
<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">
@@ -69,11 +74,21 @@ export default async function EntrepreneursPage() {
<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>
)}
<div className="flex flex-col gap-1">
{business.owner?.isSuspended ? (
<span className="badge badge-error flex items-center gap-1">
<ShieldX className="w-3 h-3" /> Compte Suspendu
</span>
) : business.isSuspended ? (
<span className="badge bg-orange-500/10 text-orange-500 border border-orange-500/20 flex items-center gap-1">
<ShieldAlert className="w-3 h-3" /> Shop Masqué
</span>
) : business.verified ? (
<span className="badge badge-verified">Vérifié</span>
) : (
<span className="badge badge-pending">En attente</span>
)}
</div>
</td>
<td className="text-center font-medium text-slate-300">
{business.viewCount.toLocaleString()}
@@ -82,7 +97,18 @@ export default async function EntrepreneursPage() {
<FeaturedModal business={business} />
</td>
<td className="text-right">
<ToggleVerifyButton id={business.id} verified={business.verified} />
<div className="flex items-center justify-end gap-3">
<ToggleVerifyButton id={business.id} verified={business.verified} />
<div className="h-8 w-[1px] bg-slate-800 mx-1" />
<EntrepreneurActions
businessId={business.id}
businessName={business.name}
userId={business.owner.id}
userName={business.owner.name}
isBusinessSuspended={!!business.isSuspended}
isUserSuspended={!!business.owner?.isSuspended}
/>
</div>
</td>
</tr>
))

View File

@@ -144,6 +144,24 @@ body {
opacity: 0.9;
}
.btn-action {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid transparent;
cursor: pointer;
font-size: 0.75rem;
font-weight: 600;
transition: all 0.2s;
}
.btn-action:hover {
opacity: 1;
transform: translateY(-1px);
}
.badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
@@ -161,6 +179,11 @@ body {
color: #f59e0b;
}
.badge-error {
background-color: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* Modals */
.modal-overlay {
position: fixed;
@@ -185,11 +208,87 @@ body {
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;
}
/* Quill Dark Theme Overrides */
.quill-dark-wrapper .ql-toolbar.ql-snow {
background-color: var(--secondary);
border-color: var(--border);
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
}
.quill-dark-wrapper .ql-container.ql-snow {
background-color: var(--background);
border-color: var(--border);
border-bottom-left-radius: 0.5rem;
border-bottom-right-radius: 0.5rem;
min-height: 300px;
font-family: inherit;
font-size: 1rem;
}
.quill-dark-wrapper .ql-snow .ql-stroke {
stroke: var(--foreground);
}
.quill-dark-wrapper .ql-snow .ql-fill {
fill: var(--foreground);
}
.quill-dark-wrapper .ql-snow .ql-picker {
color: var(--foreground);
}
.quill-dark-wrapper .ql-snow .ql-picker-options {
background-color: var(--secondary);
border-color: var(--border);
}
.quill-dark-wrapper .ql-snow .ql-picker-item:hover,
.quill-dark-wrapper .ql-snow .ql-picker-label:hover {
color: var(--primary);
}
.quill-dark-wrapper .ql-snow .ql-active {
color: var(--primary) !important;
}
.quill-dark-wrapper .ql-snow .ql-active .ql-stroke {
stroke: var(--primary) !important;
}
.quill-dark-wrapper .ql-editor.ql-blank::before {
color: var(--muted);
font-style: normal;
}
/* Styles for the Divider (HR) */
.quill-dark-wrapper .ql-editor hr {
border: none;
border-top: 2px solid var(--border);
margin: 1.5em 0;
cursor: default;
}
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider {
width: 32px;
position: relative;
}
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 2px;
background-color: var(--foreground);
}
.quill-dark-wrapper .ql-snow.ql-toolbar button.ql-divider:hover::before {
background-color: var(--primary);
}

View File

@@ -2,8 +2,8 @@ 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;
export default async function EditInterviewPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const interview = await prisma.interview.findUnique({
where: { id },
});

View File

@@ -0,0 +1,135 @@
import { getReports } from '@/app/actions/moderation';
import ReportActionButtons from '@/components/ReportActionButtons';
import ModerationSuspensionButton from '@/components/ModerationSuspensionButton';
import { Flag, MessageCircle, User, Calendar } from 'lucide-react';
export default async function ModerationPage() {
const reports = await getReports();
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2 flex items-center gap-3">
<Flag className="text-red-500" />
Modération des Messages
</h1>
<p className="text-slate-400">Gérer les signalements de contenus inappropriés dans les discussions.</p>
</div>
<div className="space-y-6">
{reports.length === 0 ? (
<div className="card p-10 text-center text-slate-500">
Aucun signalement en attente.
</div>
) : (
reports.map((report) => (
<div key={report.id} className={`card border-l-4 ${report.status === 'PENDING' ? 'border-red-500' : report.status === 'RESOLVED' ? 'border-green-500' : 'border-slate-600'}`}>
<div className="flex flex-col md:flex-row gap-6">
{/* 1. Report Info */}
<div className="md:w-1/3 space-y-4">
<div className="flex items-center gap-2 text-slate-400 text-sm">
<Calendar className="w-4 h-4" />
{new Date(report.createdAt).toLocaleString('fr-FR')}
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Signalé par</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-slate-400 font-bold text-xs">
{report.reporter.name.charAt(0)}
</div>
<div>
<div className="text-sm font-bold text-white">{report.reporter.name}</div>
<div className="text-xs text-slate-500">{report.reporter.email}</div>
</div>
</div>
</div>
<div>
<div className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">Raison invoquée</div>
<p className="text-sm text-slate-300 italic">"{report.reason}"</p>
</div>
<div className="pt-2">
<span className={`badge ${
report.status === 'PENDING' ? 'badge-pending' :
report.status === 'RESOLVED' ? 'badge-verified' :
'bg-slate-700 text-slate-300'
}`}>
{report.status === 'PENDING' ? 'En attente' :
report.status === 'RESOLVED' ? 'Résolu' :
'Rejeté'}
</span>
</div>
</div>
{/* 2. Conversation History */}
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 flex flex-col overflow-hidden">
<div className="bg-slate-800/50 p-3 border-b border-slate-700 flex items-center justify-between">
<div className="flex items-center gap-2 text-indigo-400">
<MessageCircle className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-widest text-slate-300">Historique de la discussion</span>
</div>
{report.message.conversation.business && (
<div className="text-[10px] text-slate-500 font-medium">
Shop: <span className="text-indigo-400">{report.message.conversation.business.name}</span>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto max-h-[400px] p-4 space-y-3 bg-slate-950/20">
{report.message.conversation.messages.map((msg: any) => {
const isReported = msg.id === report.messageId;
const senderName = msg.sender.name;
return (
<div key={msg.id} className={`flex flex-col ${isReported ? 'bg-red-500/10 border-red-500/30' : 'bg-slate-800/40 border-slate-700/50'} border rounded-lg p-3 transition-colors`}>
<div className="flex items-center justify-between mb-1">
<span className={`text-[10px] font-bold uppercase tracking-tighter ${isReported ? 'text-red-400' : 'text-slate-400'}`}>
{senderName}
</span>
<span className="text-[9px] text-slate-500">
{new Date(msg.createdAt).toLocaleString('fr-FR', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })}
</span>
</div>
<div className={`text-sm ${isReported ? 'text-white font-medium' : 'text-slate-300'}`}>
{msg.content}
</div>
{isReported && (
<div className="mt-2 text-[9px] flex items-center gap-1 text-red-500 font-bold uppercase">
<Flag className="w-3 h-3" />
Message Signalé
</div>
)}
</div>
);
})}
</div>
{report.status === 'PENDING' && (
<div className="p-4 bg-slate-800/30 border-t border-slate-700">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div>
<div className="mb-3 text-[10px] text-slate-500 font-bold uppercase">Résoudre le signalement</div>
<ReportActionButtons reportId={report.id} />
</div>
<div>
<div className="mb-3 text-[10px] text-slate-500 font-bold uppercase">Sanctionner l'auteur</div>
<ModerationSuspensionButton
userId={report.message.senderId}
userName={report.message.sender.name}
isSuspended={!!report.message.sender.isSuspended}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
);
}