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

12
admin/check-koliko.ts Normal file
View 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());

View File

@@ -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",

View File

@@ -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",

View File

@@ -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
}

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

View File

@@ -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>

View 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.`}
/>
</>
);
}

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

View File

@@ -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>

View 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 ?`}
/>
</>
);
}

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

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

View File

@@ -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' },
];

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

View File

@@ -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
View 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()