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:
12
admin/check-koliko.ts
Normal file
12
admin/check-koliko.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const business = await prisma.business.findFirst({
|
||||
where: { name: { contains: 'Koliko', mode: 'insensitive' } },
|
||||
include: { owner: true }
|
||||
});
|
||||
console.log(JSON.stringify(business, null, 2));
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
84
admin/package-lock.json
generated
84
admin/package-lock.json
generated
@@ -20,7 +20,8 @@
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-quill-new": "^3.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -4123,6 +4124,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exsolve": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||
@@ -4157,6 +4164,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@@ -5628,6 +5641,25 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -6168,6 +6200,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -6557,6 +6595,35 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rc9": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz",
|
||||
@@ -6612,6 +6679,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-quill-new": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"quill": "~2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"quill-delta": "^5.1.0",
|
||||
"react": "^16 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-hot-toast": "^2.6.0"
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-quill-new": "^3.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -19,11 +19,21 @@ model User {
|
||||
password String
|
||||
role UserRole @default(VISITOR)
|
||||
avatar String?
|
||||
phone String?
|
||||
bio String? @db.Text
|
||||
location String?
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
|
||||
businesses Business[]
|
||||
businesses Business?
|
||||
comments Comment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Messaging relations
|
||||
sentMessages Message[]
|
||||
conversations ConversationParticipant[]
|
||||
reports MessageReport[]
|
||||
}
|
||||
|
||||
model Business {
|
||||
@@ -39,14 +49,23 @@ model Business {
|
||||
// Contact & Social
|
||||
contactEmail String
|
||||
contactPhone String?
|
||||
websiteUrl String?
|
||||
// We use JSON to store social links (facebook, linkedin, instagram, website)
|
||||
socialLinks Json?
|
||||
|
||||
// Privacy & Status
|
||||
showEmail Boolean @default(true)
|
||||
showPhone Boolean @default(true)
|
||||
showSocials Boolean @default(true)
|
||||
isActive Boolean @default(false)
|
||||
|
||||
// Stats
|
||||
verified Boolean @default(false)
|
||||
viewCount Int @default(0)
|
||||
rating Float @default(0.0)
|
||||
tags String[]
|
||||
isSuspended Boolean @default(false)
|
||||
suspensionReason String?
|
||||
|
||||
// Entrepreneur of the Month fields
|
||||
isFeatured Boolean @default(false)
|
||||
@@ -59,6 +78,7 @@ model Business {
|
||||
owner User @relation(fields: [ownerId], references: [id])
|
||||
offers Offer[]
|
||||
comments Comment[]
|
||||
conversations Conversation[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -146,3 +166,57 @@ model AnalyticsEvent {
|
||||
metadata Json? // Browser, OS, etc.
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
participants ConversationParticipant[]
|
||||
messages Message[]
|
||||
}
|
||||
|
||||
model ConversationParticipant {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
conversationId String
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
isArchived Boolean @default(false)
|
||||
|
||||
@@unique([userId, conversationId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
content String @db.Text
|
||||
senderId String
|
||||
conversationId String
|
||||
createdAt DateTime @default(now())
|
||||
read Boolean @default(false)
|
||||
|
||||
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
reports MessageReport[]
|
||||
}
|
||||
|
||||
model MessageReport {
|
||||
id String @id @default(uuid())
|
||||
reason String?
|
||||
messageId String
|
||||
reporterId String
|
||||
status ReportStatus @default(PENDING)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum ReportStatus {
|
||||
PENDING
|
||||
RESOLVED
|
||||
DISMISSED
|
||||
}
|
||||
|
||||
52
admin/src/app/actions/moderation.ts
Normal file
52
admin/src/app/actions/moderation.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
125
admin/src/app/actions/suspension.ts
Normal file
125
admin/src/app/actions/suspension.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
27
admin/src/app/actions/user.ts
Normal file
27
admin/src/app/actions/user.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
110
admin/src/app/clients/page.tsx
Normal file
110
admin/src/app/clients/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
135
admin/src/app/moderation/page.tsx
Normal file
135
admin/src/app/moderation/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useTransition, useState } 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';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
|
||||
interface Props {
|
||||
initialData?: {
|
||||
@@ -21,6 +22,7 @@ interface Props {
|
||||
export default function BlogForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [content, setContent] = useState(initialData?.content || '');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -28,7 +30,7 @@ export default function BlogForm({ initialData }: Props) {
|
||||
const data = {
|
||||
title: formData.get('title') as string,
|
||||
excerpt: formData.get('excerpt') as string,
|
||||
content: formData.get('content') as string,
|
||||
content: content,
|
||||
author: formData.get('author') as string,
|
||||
imageUrl: formData.get('imageUrl') as string,
|
||||
};
|
||||
@@ -110,12 +112,9 @@ export default function BlogForm({ initialData }: Props) {
|
||||
|
||||
<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"
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="Rédigez votre article ici..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
67
admin/src/components/ClientActions.tsx
Normal file
67
admin/src/components/ClientActions.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import SuspensionModal from './SuspensionModal';
|
||||
|
||||
interface ClientActionsProps {
|
||||
userId: string;
|
||||
userName: string;
|
||||
isSuspended: boolean;
|
||||
}
|
||||
|
||||
export default function ClientActions({ userId, userName, isSuspended }: ClientActionsProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleSuspend = async (reason: string) => {
|
||||
const result = await suspendUser(userId, reason);
|
||||
if (result.success) {
|
||||
toast.success(`${userName} a été suspendu.`);
|
||||
} else {
|
||||
toast.error(result.error || "Échec de la suspension");
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnsuspend = async () => {
|
||||
const result = await unsuspendUser(userId);
|
||||
if (result.success) {
|
||||
toast.success(`${userName} a été réactivé.`);
|
||||
} else {
|
||||
toast.error(result.error || "Échec de la réactivation");
|
||||
}
|
||||
};
|
||||
|
||||
if (isSuspended) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleUnsuspend}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-green-500/20"
|
||||
>
|
||||
<ShieldCheck className="w-3 h-3" />
|
||||
Réactiver
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-[10px] font-bold uppercase border border-rose-500/20"
|
||||
>
|
||||
<ShieldAlert className="w-3 h-3" />
|
||||
Suspendre
|
||||
</button>
|
||||
|
||||
<SuspensionModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onConfirm={handleSuspend}
|
||||
title="Suspendre le compte"
|
||||
subtitle={`Voulez-vous suspendre le compte de ${userName} ? L'utilisateur sera bloqué et recevra votre message.`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
98
admin/src/components/EntrepreneurActions.tsx
Normal file
98
admin/src/components/EntrepreneurActions.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ShieldAlert, ShieldCheck, ShoppingBag, User } from 'lucide-react';
|
||||
import { suspendUser, unsuspendUser, suspendBusiness, unsuspendBusiness } from '@/app/actions/suspension';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import SuspensionModal from './SuspensionModal';
|
||||
|
||||
interface EntrepreneurActionsProps {
|
||||
businessId: string;
|
||||
businessName: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
isBusinessSuspended: boolean;
|
||||
isUserSuspended: boolean;
|
||||
}
|
||||
|
||||
export default function EntrepreneurActions({
|
||||
businessId,
|
||||
businessName,
|
||||
userId,
|
||||
userName,
|
||||
isBusinessSuspended,
|
||||
isUserSuspended
|
||||
}: EntrepreneurActionsProps) {
|
||||
const [modalType, setModalType] = useState<'USER' | 'BUSINESS' | null>(null);
|
||||
|
||||
const handleConfirm = async (reason: string) => {
|
||||
if (modalType === 'USER') {
|
||||
const result = await suspendUser(userId, reason);
|
||||
if (result.success) toast.success(`Compte de ${userName} suspendu.`);
|
||||
else toast.error(result.error || "Erreur");
|
||||
} else if (modalType === 'BUSINESS') {
|
||||
const result = await suspendBusiness(businessId, reason);
|
||||
if (result.success) toast.success(`Boutique ${businessName} suspendue.`);
|
||||
else toast.error(result.error || "Erreur");
|
||||
}
|
||||
setModalType(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
{/* User Suspension */}
|
||||
{isUserSuspended ? (
|
||||
<button
|
||||
onClick={() => unsuspendUser(userId)}
|
||||
className="btn-action bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white"
|
||||
title="Activer le compte"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span className="text-[10px] uppercase font-bold">Compte</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setModalType('USER')}
|
||||
className="btn-action bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white"
|
||||
title="Suspendre le compte complet"
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
<span className="text-[10px] uppercase font-bold">Compte</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Business Suspension */}
|
||||
{isBusinessSuspended ? (
|
||||
<button
|
||||
onClick={() => unsuspendBusiness(businessId)}
|
||||
className="btn-action bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500 hover:text-white"
|
||||
title="Activer la boutique"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span className="text-[10px] uppercase font-bold">Shop</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setModalType('BUSINESS')}
|
||||
className="btn-action bg-orange-500/10 text-orange-500 hover:bg-orange-500 hover:text-white"
|
||||
title="Suspendre uniquement la boutique"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<span className="text-[10px] uppercase font-bold">Shop</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SuspensionModal
|
||||
isOpen={modalType !== null}
|
||||
onClose={() => setModalType(null)}
|
||||
onConfirm={handleConfirm}
|
||||
title={modalType === 'USER' ? "Suspendre l'utilisateur" : "Suspendre la boutique"}
|
||||
subtitle={modalType === 'USER'
|
||||
? `Voulez-vous suspendre TOUT le compte de ${userName} ? Sa boutique sera également masquée.`
|
||||
: `Voulez-vous masquer la boutique "${businessName}" ? L'entrepreneur gardera l'accès à son compte.`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { useTransition, useState } 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';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import RichTextEditor from './RichTextEditor';
|
||||
|
||||
interface Props {
|
||||
initialData?: any;
|
||||
@@ -15,6 +16,7 @@ interface Props {
|
||||
export default function InterviewForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [content, setContent] = useState(initialData?.content || '');
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -28,7 +30,7 @@ export default function InterviewForm({ initialData }: Props) {
|
||||
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,
|
||||
content: content || null,
|
||||
duration: formData.get('duration') as string || null,
|
||||
};
|
||||
|
||||
@@ -161,11 +163,10 @@ export default function InterviewForm({ initialData }: Props) {
|
||||
|
||||
<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"
|
||||
<RichTextEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="Rédigez la transcription ou le contenu ici..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
62
admin/src/components/ModerationSuspensionButton.tsx
Normal file
62
admin/src/components/ModerationSuspensionButton.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { ShieldAlert, ShieldCheck } from 'lucide-react';
|
||||
import { suspendUser, unsuspendUser } from '@/app/actions/suspension';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import SuspensionModal from './SuspensionModal';
|
||||
|
||||
interface ModerationSuspensionButtonProps {
|
||||
userId: string;
|
||||
userName: string;
|
||||
isSuspended: boolean;
|
||||
}
|
||||
|
||||
export default function ModerationSuspensionButton({
|
||||
userId,
|
||||
userName,
|
||||
isSuspended
|
||||
}: ModerationSuspensionButtonProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleSuspend = async (reason: string) => {
|
||||
const result = await suspendUser(userId, reason);
|
||||
if (result.success) {
|
||||
toast.success(`${userName} a été suspendu.`);
|
||||
} else {
|
||||
toast.error(result.error || "Échec de la suspension");
|
||||
}
|
||||
};
|
||||
|
||||
if (isSuspended) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => unsuspendUser(userId)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-green-500/10 text-green-500 hover:bg-green-500 hover:text-white rounded-lg transition-all text-xs font-bold"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Réactiver le compte de l'auteur
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-rose-500/10 text-rose-500 hover:bg-rose-500 hover:text-white rounded-lg transition-all text-xs font-bold"
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
Suspendre l'auteur du message
|
||||
</button>
|
||||
|
||||
<SuspensionModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onConfirm={handleSuspend}
|
||||
title="Suspendre l'auteur"
|
||||
subtitle={`Voulez-vous suspendre le compte de ${userName} pour ce message non conforme ?`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
admin/src/components/ReportActionButtons.tsx
Normal file
53
admin/src/components/ReportActionButtons.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from 'react';
|
||||
import { updateReportStatus } from '@/app/actions/moderation';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface Props {
|
||||
reportId: string;
|
||||
}
|
||||
|
||||
export default function ReportActionButtons({ reportId }: Props) {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const handleAction = (status: 'RESOLVED' | 'DISMISSED') => {
|
||||
startTransition(async () => {
|
||||
const result = await updateReportStatus(reportId, status);
|
||||
if (result.success) {
|
||||
toast.success(status === 'RESOLVED' ? "Signalement résolu" : "Signalement rejeté");
|
||||
} else {
|
||||
toast.error(result.error || "Une erreur est survenue");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => handleAction('RESOLVED')}
|
||||
disabled={isPending}
|
||||
className="btn-verify px-3 py-1.5 text-xs"
|
||||
title="Marquer comme résolu"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <CheckCircle className="w-3 h-3" />}
|
||||
Résoudre
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleAction('DISMISSED')}
|
||||
disabled={isPending}
|
||||
className="btn-unverify px-3 py-1.5 text-xs bg-slate-700 hover:bg-slate-600 border-none"
|
||||
title="Rejeter le signalement"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <XCircle className="w-3 h-3" />}
|
||||
Rejeter
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
admin/src/components/RichTextEditor.tsx
Normal file
78
admin/src/components/RichTextEditor.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import 'react-quill-new/dist/quill.snow.css';
|
||||
|
||||
// We need to import Quill for registration
|
||||
const ReactQuill = dynamic(async () => {
|
||||
const { default: RQ, Quill } = await import("react-quill-new");
|
||||
|
||||
// Register custom Divider Blot
|
||||
if (Quill) {
|
||||
const BlockEmbed = Quill.import('blots/block/embed') as any;
|
||||
class DividerBlot extends BlockEmbed {
|
||||
static blotName = 'divider';
|
||||
static tagName = 'hr';
|
||||
}
|
||||
Quill.register(DividerBlot);
|
||||
}
|
||||
|
||||
return RQ;
|
||||
}, {
|
||||
ssr: false,
|
||||
loading: () => <div className="h-64 w-full bg-slate-900 animate-pulse rounded-lg border border-slate-700" />
|
||||
});
|
||||
|
||||
interface RichTextEditorProps {
|
||||
value: string;
|
||||
onChange: (content: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({ value, onChange, placeholder }: RichTextEditorProps) {
|
||||
const quillRef = useRef<any>(null);
|
||||
|
||||
const modules = useMemo(() => ({
|
||||
toolbar: {
|
||||
container: [
|
||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||
['divider', 'link', 'clean'],
|
||||
],
|
||||
handlers: {
|
||||
divider: function() {
|
||||
const quill = (quillRef.current as any)?.getEditor();
|
||||
if (quill) {
|
||||
const range = quill.getSelection(true);
|
||||
quill.insertEmbed(range.index, 'divider', true);
|
||||
quill.setSelection(range.index + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}), []);
|
||||
|
||||
const formats = [
|
||||
'header',
|
||||
'bold', 'italic', 'underline', 'strike',
|
||||
'list', 'bullet',
|
||||
'divider', 'link',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="quill-dark-wrapper">
|
||||
<ReactQuill
|
||||
ref={quillRef}
|
||||
theme="snow"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
modules={modules}
|
||||
formats={formats}
|
||||
placeholder={placeholder}
|
||||
className="bg-slate-900 text-white rounded-lg overflow-hidden border border-slate-700 min-h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,14 +9,17 @@ import {
|
||||
BookOpen,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
BarChart3
|
||||
BarChart3,
|
||||
Flag
|
||||
} from 'lucide-react';
|
||||
|
||||
const menuItems = [
|
||||
{ name: 'Dashboard', icon: LayoutDashboard, href: '/dashboard' },
|
||||
{ name: 'Entrepreneurs', icon: ShieldCheck, href: '/entrepreneurs' },
|
||||
{ name: 'Clients', icon: Users, href: '/clients' },
|
||||
{ name: 'Metrics & Analytics', icon: BarChart3, href: '/metrics' },
|
||||
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
|
||||
{ name: 'Modération', icon: Flag, href: '/moderation' },
|
||||
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
|
||||
];
|
||||
|
||||
|
||||
111
admin/src/components/SuspensionModal.tsx
Normal file
111
admin/src/components/SuspensionModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ShieldAlert, X, AlertOctagon } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
interface SuspensionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => Promise<void>;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
export default function SuspensionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
subtitle
|
||||
}: SuspensionModalProps) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
return () => setMounted(false);
|
||||
}, []);
|
||||
|
||||
if (!isOpen || !mounted) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!reason.trim()) {
|
||||
toast.error("Veuillez saisir un motif de suspension");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onConfirm(reason);
|
||||
setReason('');
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div className="modal-overlay" style={{ zIndex: 9999 }}>
|
||||
<div className="modal-content max-w-md shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3 text-red-500">
|
||||
<ShieldAlert className="w-7 h-7" />
|
||||
<h2 className="text-2xl font-bold text-white">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-full hover:bg-slate-800 text-slate-500 hover:text-white transition-all"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="bg-red-500/10 border border-red-500/20 p-4 rounded-xl flex gap-3 mb-6">
|
||||
<AlertOctagon className="w-6 h-6 text-red-500 shrink-0" />
|
||||
<p className="text-sm text-slate-300 leading-relaxed font-medium">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-widest">
|
||||
Motif de la suspension
|
||||
</label>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Expliquez pourquoi ce compte est suspendu. Ce message sera visible par l'utilisateur."
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-4 text-white focus:outline-none focus:border-red-500 transition-all text-sm shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-white rounded-xl transition-all font-bold text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-3 bg-red-600 hover:bg-red-700 disabled:opacity-50 text-white rounded-xl transition-all font-bold text-sm shadow-lg shadow-red-900/40"
|
||||
>
|
||||
{isSubmitting ? "En cours..." : "Confirmer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaPg } from '@prisma/adapter-pg'
|
||||
// Updated: 2026-04-10
|
||||
// Updated: 2026-04-12 08:34 (Refreshed for new models)
|
||||
import pg from 'pg'
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!
|
||||
@@ -11,10 +11,10 @@ function makePrisma() {
|
||||
return new PrismaClient({ adapter })
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
|
||||
const globalForPrisma = globalThis as unknown as { prisma_v2: PrismaClient }
|
||||
|
||||
export const prisma = globalForPrisma.prisma || makePrisma()
|
||||
export const prisma = globalForPrisma.prisma_v2 || makePrisma()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v2 = prisma
|
||||
|
||||
export default prisma
|
||||
|
||||
4
admin/test-prisma.ts
Normal file
4
admin/test-prisma.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
console.log('Models available in Prisma:', Object.keys(prisma).filter(k => !k.startsWith('_')))
|
||||
prisma.$disconnect()
|
||||
Reference in New Issue
Block a user