feat: add home featured toggle, improve account deletion UI, and update business schema and database configuration.
This commit is contained in:
5
.env
5
.env
@@ -1,9 +1,6 @@
|
|||||||
# Connexion locale via le Docker Compose fourni :
|
# Connexion locale via le Docker Compose fourni :
|
||||||
#DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
|
#DATABASE_URL="postgresql://admin:adminpassword@localhost:5432/afrohub_dev?schema=public"
|
||||||
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?connect_timeout=300"
|
DATABASE_URL="postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
|
|
||||||
#SHADOW_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/shadow_db?schema=public"
|
|
||||||
|
|
||||||
# Exemple avec Supabase ou Neon (si base distante)
|
# Exemple avec Supabase ou Neon (si base distante)
|
||||||
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
# DATABASE_URL="postgresql://postgres:[MOT_DE_PASSE]@db.[ID_PROJET].supabase.co:5432/postgres"
|
||||||
|
|||||||
@@ -24,21 +24,24 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
keyMetric: string;
|
keyMetric: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
// 1. Reset all others (we only want one entrepreneur of the month)
|
// 1. Réinitialiser les autres pour n'avoir qu'une seule Tête d'affiche Afroshine active
|
||||||
await prisma.business.updateMany({
|
await prisma.business.updateMany({
|
||||||
where: { isFeatured: true },
|
where: { isFeatured: true },
|
||||||
data: { isFeatured: false },
|
data: { isFeatured: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Set the new one
|
// 2. Mettre à jour l'entreprise choisie
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
isFeatured: true,
|
isFeatured: true,
|
||||||
...data,
|
founderName: data.founderName || null,
|
||||||
|
founderImageUrl: data.founderImageUrl || null,
|
||||||
|
keyMetric: data.keyMetric || null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
revalidatePath("/users");
|
||||||
revalidatePath("/entrepreneurs");
|
revalidatePath("/entrepreneurs");
|
||||||
revalidatePath("/dashboard");
|
revalidatePath("/dashboard");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -48,12 +51,28 @@ export async function setFeaturedBusiness(id: string, data: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleHomeFeatured(id: string, currentStatus: boolean) {
|
||||||
|
try {
|
||||||
|
await prisma.business.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isHomeFeatured: !currentStatus },
|
||||||
|
});
|
||||||
|
revalidatePath("/users");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to toggle home featured:", error);
|
||||||
|
return { success: false, error: "Erreur lors de la mise à jour de la mise à la une" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function removeFeaturedBusiness(id: string) {
|
export async function removeFeaturedBusiness(id: string) {
|
||||||
try {
|
try {
|
||||||
await prisma.business.update({
|
await prisma.business.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isFeatured: false },
|
data: { isFeatured: false },
|
||||||
});
|
});
|
||||||
|
revalidatePath("/users");
|
||||||
revalidatePath("/entrepreneurs");
|
revalidatePath("/entrepreneurs");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,19 +6,48 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Clock,
|
Clock,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
Eye
|
Eye,
|
||||||
|
CheckCircle2,
|
||||||
|
UserPlus
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Stats {
|
interface DashboardData {
|
||||||
usersCount: number;
|
stats: {
|
||||||
businessCount: number;
|
usersCount: number;
|
||||||
pendingCount: number;
|
businessCount: number;
|
||||||
commentsCount: number;
|
pendingCount: number;
|
||||||
totalViews: number;
|
commentsCount: number;
|
||||||
uniqueVisitors: number;
|
totalViews: number;
|
||||||
|
uniqueVisitors: number;
|
||||||
|
};
|
||||||
|
latestBusinesses: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
logoUrl: string;
|
||||||
|
location: string;
|
||||||
|
createdAt: Date;
|
||||||
|
verified: boolean;
|
||||||
|
plan: string;
|
||||||
|
owner?: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
activities: Array<{
|
||||||
|
id: string;
|
||||||
|
type: 'comment' | 'user';
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
timestamp: Date;
|
||||||
|
avatar?: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardClient({ stats }: { stats: Stats }) {
|
export default function DashboardClient({ initialData }: { initialData: DashboardData }) {
|
||||||
|
const { stats, latestBusinesses, activities } = initialData;
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
|
||||||
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
|
||||||
@@ -50,16 +79,86 @@ export default function DashboardClient({ stats }: { stats: Stats }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="card">
|
{/* Derniers entrepreneurs */}
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Derniers entrepreneurs</h2>
|
<div className="card flex flex-col">
|
||||||
<div className="space-y-4">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
<h2 className="text-xl font-bold text-white">Derniers entrepreneurs</h2>
|
||||||
|
<Link href="/users" className="text-xs text-indigo-400 hover:underline font-semibold">
|
||||||
|
Voir tout
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{latestBusinesses.length === 0 ? (
|
||||||
|
<p className="text-slate-500 italic py-8 text-center">Aucune entreprise récente.</p>
|
||||||
|
) : (
|
||||||
|
latestBusinesses.map((b) => (
|
||||||
|
<div key={b.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-800/40 hover:bg-slate-800/80 transition-colors border border-slate-800">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-slate-800 overflow-hidden flex items-center justify-center border border-slate-700 flex-shrink-0">
|
||||||
|
{b.logoUrl ? (
|
||||||
|
<img src={b.logoUrl} alt={b.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Store className="w-4 h-4 text-slate-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-white text-sm truncate">{b.name}</div>
|
||||||
|
<div className="text-xs text-slate-400 truncate">{b.category} • {b.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||||
|
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full uppercase ${
|
||||||
|
b.plan === 'EMPIRE' ? 'bg-purple-500/10 text-purple-400 border border-purple-500/20' :
|
||||||
|
b.plan === 'BOOSTER' ? 'bg-amber-500/10 text-amber-400 border border-amber-500/20' :
|
||||||
|
'bg-slate-500/10 text-slate-400'
|
||||||
|
}`}>
|
||||||
|
{b.plan}
|
||||||
|
</span>
|
||||||
|
{b.verified ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-emerald-500" title="Vérifié" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-4 h-4 text-amber-500" title="En attente" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-bold text-white mb-4">Activité récente</h2>
|
{/* Activité récente */}
|
||||||
<div className="space-y-4">
|
<div className="card flex flex-col">
|
||||||
<p className="text-slate-500 italic">Bientôt disponible...</p>
|
<h2 className="text-xl font-bold text-white mb-6">Activité récente</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{activities.length === 0 ? (
|
||||||
|
<p className="text-slate-500 italic py-8 text-center">Aucune activité enregistrée.</p>
|
||||||
|
) : (
|
||||||
|
activities.map((act) => (
|
||||||
|
<div key={act.id} className="flex items-start gap-3.5 p-3 rounded-xl bg-slate-800/20 border border-slate-800/50">
|
||||||
|
<div className="mt-0.5 w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center border border-slate-700 flex-shrink-0 overflow-hidden">
|
||||||
|
{act.avatar ? (
|
||||||
|
<img src={act.avatar} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : act.type === 'comment' ? (
|
||||||
|
<MessageCircle className="w-3.5 h-3.5 text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs font-semibold text-slate-200 truncate">{act.title}</p>
|
||||||
|
<span className="text-[10px] text-slate-500 flex-shrink-0">
|
||||||
|
{new Date(act.timestamp).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{act.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { prisma } from '@/lib/prisma';
|
import { prisma } from '@/lib/prisma';
|
||||||
import DashboardClient from './DashboardClient';
|
import DashboardClient from './DashboardClient';
|
||||||
|
|
||||||
async function getStats() {
|
async function getDashboardData() {
|
||||||
const usersCount = await prisma.user.count();
|
const usersCount = await prisma.user.count();
|
||||||
const businessCount = await prisma.business.count();
|
const businessCount = await prisma.business.count();
|
||||||
const pendingCount = await prisma.business.count({ where: { verified: false } });
|
const pendingCount = await prisma.business.count({ where: { verified: false } });
|
||||||
@@ -21,10 +21,81 @@ async function getStats() {
|
|||||||
});
|
});
|
||||||
const uniqueVisitors = uniqueVisitorsRes.length;
|
const uniqueVisitors = uniqueVisitorsRes.length;
|
||||||
|
|
||||||
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors };
|
// Derniers entrepreneurs
|
||||||
|
const latestBusinesses = await prisma.business.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
category: true,
|
||||||
|
logoUrl: true,
|
||||||
|
location: true,
|
||||||
|
createdAt: true,
|
||||||
|
verified: true,
|
||||||
|
plan: true,
|
||||||
|
owner: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activité récente (combiner commentaires et nouveaux utilisateurs)
|
||||||
|
const recentComments = await prisma.comment.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
author: { select: { name: true, avatar: true } },
|
||||||
|
business: { select: { name: true } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentUsers = await prisma.user.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
role: true,
|
||||||
|
avatar: true,
|
||||||
|
createdAt: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mapper et trier
|
||||||
|
const activities = [
|
||||||
|
...recentComments.map(c => ({
|
||||||
|
id: `comment-${c.id}`,
|
||||||
|
type: 'comment' as const,
|
||||||
|
title: `${c.author?.name || 'Un utilisateur'} a commenté sur ${c.business?.name || 'une entreprise'}`,
|
||||||
|
subtitle: c.content.length > 60 ? c.content.substring(0, 60) + '...' : c.content,
|
||||||
|
timestamp: c.createdAt,
|
||||||
|
avatar: c.author?.avatar
|
||||||
|
})),
|
||||||
|
...recentUsers.map(u => ({
|
||||||
|
id: `user-${u.id}`,
|
||||||
|
type: 'user' as const,
|
||||||
|
title: `Nouvel utilisateur inscrit`,
|
||||||
|
subtitle: `${u.name} a rejoint la plateforme en tant que ${u.role === 'ENTREPRENEUR' ? 'Entrepreneur' : 'Visiteur'}`,
|
||||||
|
timestamp: u.createdAt,
|
||||||
|
avatar: u.avatar
|
||||||
|
}))
|
||||||
|
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()).slice(0, 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors },
|
||||||
|
latestBusinesses,
|
||||||
|
activities
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const stats = await getStats();
|
const data = await getDashboardData();
|
||||||
return <DashboardClient stats={stats} />;
|
return <DashboardClient initialData={data} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ body {
|
|||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
@@ -42,12 +43,16 @@ body {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-content {
|
.admin-content {
|
||||||
margin-left: 260px;
|
margin-left: 260px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
width: calc(100% - 260px);
|
||||||
|
max-width: calc(100vw - 260px);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link {
|
.nav-link {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function RootLayout({
|
|||||||
<body className={inter.className}>
|
<body className={inter.className}>
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="admin-content flex-1">
|
<main className="admin-content flex-1 min-w-0">
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma';
|
|||||||
import { getClients } from "@/app/actions/user";
|
import { getClients } from "@/app/actions/user";
|
||||||
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
import ToggleVerifyButton from '@/components/ToggleVerifyButton';
|
||||||
import FeaturedModal from '@/components/FeaturedModal';
|
import FeaturedModal from '@/components/FeaturedModal';
|
||||||
|
import ToggleHomeFeatureButton from '@/components/ToggleHomeFeatureButton';
|
||||||
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
import EntrepreneurActions from '@/components/EntrepreneurActions';
|
||||||
import PlanSelector from '@/components/PlanSelector';
|
import PlanSelector from '@/components/PlanSelector';
|
||||||
import ClientActions from "@/components/ClientActions";
|
import ClientActions from "@/components/ClientActions";
|
||||||
@@ -9,6 +10,7 @@ import ManualVerifyButton from "@/components/ManualVerifyButton";
|
|||||||
import { Trash2, ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
import { Trash2, ShieldAlert, ShieldX, ShieldCheck, Users, Briefcase, User, Mail, Phone, MapPin, MailCheck, MailX } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
// Force Turbopack native load
|
||||||
async function getBusinesses() {
|
async function getBusinesses() {
|
||||||
return await prisma.business.findMany({
|
return await prisma.business.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -87,30 +89,32 @@ export default async function UsersPage({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="card overflow-hidden p-0">
|
<div className="card overflow-hidden p-0">
|
||||||
<table className="data-table">
|
<div className="overflow-x-auto">
|
||||||
|
<table className="data-table min-w-[1200px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Entreprise</th>
|
<th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Entreprise</th>
|
||||||
<th>Catégorie</th>
|
<th>Catégorie</th>
|
||||||
<th>Propriétaire</th>
|
<th>Propriétaire</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th className="text-center">Forfait</th>
|
<th className="text-center">Forfait</th>
|
||||||
<th className="text-center">Mise en avant</th>
|
<th className="text-center whitespace-nowrap">À la une (Accueil)</th>
|
||||||
|
<th className="text-center whitespace-nowrap">Afroshine (Annuaire)</th>
|
||||||
<th className="text-right">Action</th>
|
<th className="text-right">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{businesses.length === 0 ? (
|
{businesses.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} className="text-center py-10 text-slate-500">
|
<td colSpan={9} className="text-center py-10 text-slate-500">
|
||||||
Aucun entrepreneur trouvé.
|
Aucun entrepreneur trouvé.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
businesses.map((business: any) => (
|
businesses.map((business: any) => (
|
||||||
<tr key={business.id} className={`transition-colors ${business.owner?.deletedAt ? 'bg-red-500/5 opacity-80' : (business.isSuspended || business.owner?.isSuspended) ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
<tr key={business.id} className={`transition-colors ${business.owner?.deletedAt ? 'bg-red-500/5' : (business.isSuspended || business.owner?.isSuspended) ? 'grayscale-[0.5]' : ''}`}>
|
||||||
<td>
|
<td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<div className="w-10 h-10 rounded-lg bg-slate-800 flex items-center justify-center overflow-hidden">
|
||||||
{business.logoUrl ? (
|
{business.logoUrl ? (
|
||||||
@@ -129,11 +133,11 @@ export default async function UsersPage({
|
|||||||
<span className="text-sm text-slate-300">{business.category}</span>
|
<span className="text-sm text-slate-300">{business.category}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="text-sm text-white">{business.owner.name}</div>
|
<div className="text-sm text-white">{business.owner?.name || 'Inconnu'}</div>
|
||||||
<div className="text-xs text-slate-500">{business.owner.email}</div>
|
<div className="text-xs text-slate-500">{business.owner?.email || 'N/A'}</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{business.owner.emailVerified ? (
|
{business.owner?.emailVerified ? (
|
||||||
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
<span className="flex items-center gap-1.5 text-[10px] text-emerald-500 font-bold uppercase">
|
||||||
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
<MailCheck className="w-3.5 h-3.5" /> Vérifié
|
||||||
</span>
|
</span>
|
||||||
@@ -142,7 +146,7 @@ export default async function UsersPage({
|
|||||||
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
<span className="flex items-center gap-1.5 text-[10px] text-rose-500 font-bold uppercase">
|
||||||
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
<MailX className="w-3.5 h-3.5" /> Non vérifié
|
||||||
</span>
|
</span>
|
||||||
<ManualVerifyButton userId={business.owner.id} />
|
{business.owner?.id && <ManualVerifyButton userId={business.owner.id} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -170,6 +174,9 @@ export default async function UsersPage({
|
|||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
<PlanSelector businessId={business.id} currentPlan={business.plan} />
|
||||||
</td>
|
</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<ToggleHomeFeatureButton id={business.id} isHomeFeatured={!!business.isHomeFeatured} />
|
||||||
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center">
|
||||||
<FeaturedModal business={business} />
|
<FeaturedModal business={business} />
|
||||||
</td>
|
</td>
|
||||||
@@ -180,8 +187,8 @@ export default async function UsersPage({
|
|||||||
<EntrepreneurActions
|
<EntrepreneurActions
|
||||||
businessId={business.id}
|
businessId={business.id}
|
||||||
businessName={business.name}
|
businessName={business.name}
|
||||||
userId={business.owner.id}
|
userId={business.owner?.id || ''}
|
||||||
userName={business.owner.name}
|
userName={business.owner?.name || 'Inconnu'}
|
||||||
isBusinessSuspended={!!business.isSuspended}
|
isBusinessSuspended={!!business.isSuspended}
|
||||||
isUserSuspended={!!business.owner?.isSuspended}
|
isUserSuspended={!!business.owner?.isSuspended}
|
||||||
metaTitle={business.metaTitle}
|
metaTitle={business.metaTitle}
|
||||||
@@ -194,15 +201,16 @@ export default async function UsersPage({
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="card overflow-hidden">
|
<div className="card overflow-hidden p-0">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="data-table">
|
<table className="data-table min-w-[1000px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Utilisateur</th>
|
<th className="sticky left-0 z-30 bg-[#1e293b] border-r border-slate-700">Utilisateur</th>
|
||||||
<th>Contact</th>
|
<th>Contact</th>
|
||||||
<th>Vérification</th>
|
<th>Vérification</th>
|
||||||
<th>Localisation</th>
|
<th>Localisation</th>
|
||||||
@@ -222,8 +230,8 @@ export default async function UsersPage({
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
clients.map((client: any) => (
|
clients.map((client: any) => (
|
||||||
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.deletedAt ? 'bg-red-500/5 opacity-80' : client.isSuspended ? 'opacity-60 grayscale-[0.5]' : ''}`}>
|
<tr key={client.id} className={`hover:bg-slate-800/30 transition-colors ${client.deletedAt ? 'bg-red-500/5' : client.isSuspended ? 'grayscale-[0.5]' : ''}`}>
|
||||||
<td className="py-4">
|
<td className="sticky left-0 z-20 bg-[#1e293b] border-r border-slate-700 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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 ? (
|
{client.avatar ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useTransition } from 'react';
|
import React, { useState, useEffect, useTransition } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Save, Loader2, Globe } from 'lucide-react';
|
import { X, Save, Loader2, Globe } from 'lucide-react';
|
||||||
import { updateBusinessSeo } from '@/app/actions/business';
|
import { updateBusinessSeo } from '@/app/actions/business';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -20,8 +21,14 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
|
|||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
|
const [metaTitle, setMetaTitle] = useState(business.metaTitle || '');
|
||||||
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
|
const [metaDescription, setMetaDescription] = useState(business.metaDescription || '');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen || !mounted) return null;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -39,8 +46,8 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const modalContent = (
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||||
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
|
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-sm" onClick={onClose} />
|
||||||
|
|
||||||
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
|
<div className="relative bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg shadow-2xl animate-in zoom-in duration-200">
|
||||||
@@ -117,4 +124,6 @@ export default function BusinessSeoModal({ isOpen, onClose, business }: Business
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createPortal(modalContent, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { UserX, Loader2 } from 'lucide-react';
|
import { UserX, Loader2 } from 'lucide-react';
|
||||||
import { scheduleUserDeletion } from '@/app/actions/user';
|
import { scheduleUserDeletion } from '@/app/actions/user';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -16,6 +17,12 @@ export default function DeleteAccountButton({ userId, userName }: DeleteAccountB
|
|||||||
const [reason, setReason] = useState("Non-respect des conditions générales d'utilisation");
|
const [reason, setReason] = useState("Non-respect des conditions générales d'utilisation");
|
||||||
const [customReason, setCustomReason] = useState("");
|
const [customReason, setCustomReason] = useState("");
|
||||||
const [delay, setDelay] = useState(30);
|
const [delay, setDelay] = useState(30);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const reasons = [
|
const reasons = [
|
||||||
"Non-respect des conditions générales d'utilisation",
|
"Non-respect des conditions générales d'utilisation",
|
||||||
@@ -57,6 +64,112 @@ export default function DeleteAccountButton({ userId, userName }: DeleteAccountB
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
||||||
|
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95 duration-200">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-12 h-12 bg-red-500/10 rounded-xl flex items-center justify-center text-red-500">
|
||||||
|
<UserX className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-white">Supprimer le compte</h3>
|
||||||
|
<p className="text-sm text-slate-400">Configuration de la suppression pour {userName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
{/* Raison selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Raison de la suppression</label>
|
||||||
|
<select
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white focus:ring-2 focus:ring-red-500 transition-all outline-none"
|
||||||
|
>
|
||||||
|
{reasons.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Reason Field */}
|
||||||
|
{reason === "Autre" && (
|
||||||
|
<div className="animate-in slide-in-from-top-2 duration-200">
|
||||||
|
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Précisez la raison</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
value={customReason}
|
||||||
|
onChange={(e) => setCustomReason(e.target.value)}
|
||||||
|
placeholder="Saisissez le motif ici..."
|
||||||
|
className="w-full h-24 bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-red-500 transition-all outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delay selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Délai avant suppression définitive</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{delays.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.value}
|
||||||
|
onClick={() => setDelay(d.value)}
|
||||||
|
className={`py-2 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
delay === d.value
|
||||||
|
? 'bg-red-500 border-red-500 text-white'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/50 border border-slate-800 rounded-xl p-4 mb-6">
|
||||||
|
<ul className="space-y-2 text-xs text-slate-400">
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
|
||||||
|
Le compte sera désactivé immédiatement.
|
||||||
|
</li>
|
||||||
|
{delay > 0 && (
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
|
||||||
|
L'utilisateur aura {delay} jours pour réactiver son compte.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
<li className="flex items-start gap-2">
|
||||||
|
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
|
||||||
|
Suppression définitive : {delay === 0 ? "Immédiate" : `Le ${new Date(Date.now() + delay * 86400000).toLocaleDateString()}`}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-red-600 text-white rounded-xl font-bold hover:bg-red-500 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Confirmer"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -68,111 +181,7 @@ export default function DeleteAccountButton({ userId, userName }: DeleteAccountB
|
|||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{mounted && isOpen && createPortal(modalContent, document.body)}
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm animate-in fade-in duration-200">
|
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-lg p-6 shadow-2xl animate-in zoom-in-95 duration-200">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<div className="w-12 h-12 bg-red-500/10 rounded-xl flex items-center justify-center text-red-500">
|
|
||||||
<UserX className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xl font-bold text-white">Supprimer le compte</h3>
|
|
||||||
<p className="text-sm text-slate-400">Configuration de la suppression pour {userName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
{/* Raison selector */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Raison de la suppression</label>
|
|
||||||
<select
|
|
||||||
value={reason}
|
|
||||||
onChange={(e) => setReason(e.target.value)}
|
|
||||||
className="w-full bg-slate-950 border border-slate-800 rounded-xl px-4 py-2.5 text-white focus:ring-2 focus:ring-red-500 transition-all outline-none"
|
|
||||||
>
|
|
||||||
{reasons.map((r) => (
|
|
||||||
<option key={r} value={r}>{r}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Custom Reason Field */}
|
|
||||||
{reason === "Autre" && (
|
|
||||||
<div className="animate-in slide-in-from-top-2 duration-200">
|
|
||||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Précisez la raison</label>
|
|
||||||
<textarea
|
|
||||||
required
|
|
||||||
value={customReason}
|
|
||||||
onChange={(e) => setCustomReason(e.target.value)}
|
|
||||||
placeholder="Saisissez le motif ici..."
|
|
||||||
className="w-full h-24 bg-slate-950 border border-slate-800 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-red-500 transition-all outline-none resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delay selector */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-400 uppercase mb-2">Délai avant suppression définitive</label>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{delays.map((d) => (
|
|
||||||
<button
|
|
||||||
key={d.value}
|
|
||||||
onClick={() => setDelay(d.value)}
|
|
||||||
className={`py-2 rounded-lg text-xs font-bold border transition-all ${
|
|
||||||
delay === d.value
|
|
||||||
? 'bg-red-500 border-red-500 text-white'
|
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:border-slate-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{d.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-slate-950/50 border border-slate-800 rounded-xl p-4 mb-6">
|
|
||||||
<ul className="space-y-2 text-xs text-slate-400">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
|
|
||||||
Le compte sera désactivé immédiatement.
|
|
||||||
</li>
|
|
||||||
{delay > 0 && (
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
|
|
||||||
L'utilisateur aura {delay} jours pour réactiver son compte.
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-red-500 mt-1.5 shrink-0"></span>
|
|
||||||
Suppression définitive : {delay === 0 ? "Immédiate" : `Le ${new Date(Date.now() + delay * 86400000).toLocaleDateString()}`}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="flex-1 px-4 py-2.5 bg-slate-800 text-white rounded-xl font-bold hover:bg-slate-700 transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
Annuler
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className="flex-1 px-4 py-2.5 bg-red-600 text-white rounded-xl font-bold hover:bg-red-500 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{isDeleting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
"Confirmer"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useEffect, useTransition } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
|
import { setFeaturedBusiness, removeFeaturedBusiness } from '@/app/actions/business';
|
||||||
import { Star, X, Loader2, Save } from 'lucide-react';
|
import { Star, X, Loader2, Save } from 'lucide-react';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
@@ -19,6 +20,12 @@ interface Props {
|
|||||||
export default function FeaturedModal({ business }: Props) {
|
export default function FeaturedModal({ business }: Props) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
return () => setMounted(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleToggleFeature = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -54,94 +61,97 @@ export default function FeaturedModal({ business }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modalContent = (
|
||||||
|
<div className="modal-overlay" style={{ zIndex: 9999 }}>
|
||||||
|
<div className="modal-content max-w-md shadow-2xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="absolute top-4 right-4 text-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">💰 Mise en avant / Afroshine</h2>
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
Activez la présence dans la section <strong>"Entreprises à la une"</strong> sur l'accueil. Vous pouvez aussi renseigner les détails optionnels pour l'afficher en <strong>Tête d'affiche (Afroshine)</strong> dans l'annuaire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleToggleFeature} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Nom du fondateur (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="founderName"
|
||||||
|
defaultValue={business.founderName || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Ex: Aliko Dangote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur (Optionnel)</label>
|
||||||
|
<input
|
||||||
|
name="founderImageUrl"
|
||||||
|
defaultValue={business.founderImageUrl || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-400">Métrique Clé (Optionnel, ex: Impact)</label>
|
||||||
|
<input
|
||||||
|
name="keyMetric"
|
||||||
|
defaultValue={business.keyMetric || ''}
|
||||||
|
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
||||||
|
placeholder="Ex: +500 emplois créés"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
||||||
|
Définir comme Afroshine
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{business.isFeatured && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRemove}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
|
||||||
|
>
|
||||||
|
Révoquer le titre
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={`px-3 py-1.5 rounded-lg border text-sm font-medium transition-all flex items-center gap-2 mx-auto ${
|
className={`px-2.5 py-1 rounded-md border text-xs font-medium transition-all flex items-center gap-1.5 mx-auto whitespace-nowrap w-fit ${
|
||||||
business.isFeatured
|
business.isFeatured
|
||||||
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
|
? 'bg-amber-500/10 border-amber-500/50 text-amber-500 hover:bg-amber-500/20'
|
||||||
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
|
title="Configurer la mise en avant Afroshine (Annuaire)"
|
||||||
>
|
>
|
||||||
<Star className={`w-4 h-4 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
|
<Star className={`w-3.5 h-3.5 ${business.isFeatured ? 'fill-amber-500' : ''}`} />
|
||||||
{business.isFeatured ? 'Vedette Active' : 'Mettre en avant'}
|
{business.isFeatured ? 'Actif' : 'Activer'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{mounted && isOpen && createPortal(modalContent, document.body)}
|
||||||
<div className="modal-overlay">
|
|
||||||
<div className="modal-content">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
className="absolute top-4 right-4 text-slate-500 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="mb-6">
|
|
||||||
<h2 className="text-2xl font-bold text-white mb-2">💰 Afroshine</h2>
|
|
||||||
<p className="text-slate-400">Configurez les détails pour {business.name}.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleToggleFeature} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Nom du fondateur</label>
|
|
||||||
<input
|
|
||||||
name="founderName"
|
|
||||||
defaultValue={business.founderName || ''}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
|
||||||
placeholder="Ex: Aliko Dangote"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">URL Photo du fondateur</label>
|
|
||||||
<input
|
|
||||||
name="founderImageUrl"
|
|
||||||
defaultValue={business.founderImageUrl || ''}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
|
||||||
placeholder="https://..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-400">Métrique Clé (ex: Chiffre d'affaires, Impact)</label>
|
|
||||||
<input
|
|
||||||
name="keyMetric"
|
|
||||||
defaultValue={business.keyMetric || ''}
|
|
||||||
required
|
|
||||||
className="w-full bg-slate-900 border border-slate-700 rounded-lg p-3 text-white focus:outline-none focus:border-amber-500"
|
|
||||||
placeholder="Ex: +500 emplois créés"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex flex-col gap-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full bg-amber-500 hover:bg-amber-600 text-slate-900 font-bold py-3 rounded-lg flex items-center justify-center gap-2 transition-colors"
|
|
||||||
>
|
|
||||||
{isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
|
|
||||||
Définir comme Afroshine
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{business.isFeatured && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRemove}
|
|
||||||
disabled={isPending}
|
|
||||||
className="w-full bg-slate-800 hover:bg-red-900/30 text-red-400 font-semibold py-3 rounded-lg transition-colors border border-red-900/50"
|
|
||||||
>
|
|
||||||
Révoquer le titre
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export default function Sidebar() {
|
|||||||
}, [pathname]); // Refresh on navigation
|
}, [pathname]); // Refresh on navigation
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-sidebar p-6 flex flex-col">
|
<div className="admin-sidebar z-50 p-6 flex flex-col">
|
||||||
<div className="flex items-center gap-3 mb-10 px-2">
|
<div className="flex items-center gap-3 mb-10 px-2">
|
||||||
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center">
|
||||||
<ShieldCheck className="text-white" />
|
<ShieldCheck className="text-white" />
|
||||||
|
|||||||
46
admin/src/components/ToggleHomeFeatureButton.tsx
Normal file
46
admin/src/components/ToggleHomeFeatureButton.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toggleHomeFeatured } from '@/app/actions/business';
|
||||||
|
import { Sparkles, Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
isHomeFeatured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToggleHomeFeatureButton({ id, isHomeFeatured }: Props) {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await toggleHomeFeatured(id, isHomeFeatured);
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(isHomeFeatured ? "Retiré de l'accueil" : "Ajouté à l'accueil");
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || "Une erreur est survenue");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`px-2.5 py-1 rounded-md border text-xs font-medium transition-all flex items-center gap-1.5 mx-auto whitespace-nowrap w-fit ${
|
||||||
|
isHomeFeatured
|
||||||
|
? 'bg-indigo-500/10 border-indigo-500/50 text-indigo-400 hover:bg-indigo-500/20'
|
||||||
|
: 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-white'
|
||||||
|
}`}
|
||||||
|
title="Afficher dans la section 'Entreprises à la une' sur la page d'accueil"
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Sparkles className={`w-3.5 h-3.5 ${isHomeFeatured ? 'text-indigo-400 fill-indigo-400' : ''}`} />
|
||||||
|
)}
|
||||||
|
{isHomeFeatured ? 'Actif' : 'Activer'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,28 +1,19 @@
|
|||||||
import { PrismaClient } from '@prisma/client'
|
import { PrismaClient } from '@prisma/client'
|
||||||
import { PrismaPg } from '@prisma/adapter-pg'
|
|
||||||
import pg from 'pg'
|
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma_v4?: PrismaClient }
|
const connectionString = process.env.DATABASE_URL || "postgresql://admin:adminpassword@192.168.1.25:5432/afrohub_dev?schema=public"
|
||||||
|
|
||||||
function createPrismaClient() {
|
const globalForPrisma = globalThis as unknown as { prisma_admin_native?: PrismaClient }
|
||||||
const connectionString = process.env.DATABASE_URL
|
|
||||||
|
|
||||||
if (!connectionString && process.env.NODE_ENV === 'production') {
|
|
||||||
console.warn("DATABASE_URL is missing during build.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const pool = new pg.Pool({ connectionString: connectionString || "" })
|
export const prisma = globalForPrisma.prisma_admin_native || new PrismaClient({
|
||||||
const adapter = new PrismaPg(pool)
|
datasources: {
|
||||||
return new PrismaClient({ adapter })
|
db: {
|
||||||
}
|
url: connectionString,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_admin_native = prisma
|
||||||
|
|
||||||
|
export default prisma
|
||||||
|
|
||||||
export const prisma = new Proxy({} as PrismaClient, {
|
|
||||||
get(target, prop) {
|
|
||||||
if (!globalForPrisma.prisma_v4) {
|
|
||||||
globalForPrisma.prisma_v4 = createPrismaClient();
|
|
||||||
}
|
|
||||||
return (globalForPrisma.prisma_v4 as any)[prop];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default prisma;
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default async function HomePage() {
|
|||||||
// Fetch initial data for faster loading and SEO
|
// Fetch initial data for faster loading and SEO
|
||||||
const [featuredBusinesses, posts, categories, slides] = await Promise.all([
|
const [featuredBusinesses, posts, categories, slides] = await Promise.all([
|
||||||
prisma.business.findMany({
|
prisma.business.findMany({
|
||||||
where: { isFeatured: true, isActive: true, isSuspended: false },
|
where: { isHomeFeatured: true, isActive: true, isSuspended: false },
|
||||||
take: 4,
|
take: 4,
|
||||||
include: { categoryRef: true }
|
include: { categoryRef: true }
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ const AnnuaireHero = ({ featuredBusiness }: { featuredBusiness: Business }) => {
|
|||||||
<h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2">
|
<h2 className="text-3xl md:text-4xl font-bold font-serif text-gray-900 mb-2">
|
||||||
{featuredBusiness.name}
|
{featuredBusiness.name}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-gray-600 mb-4 font-medium">
|
{featuredBusiness.founderName && (
|
||||||
Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span>
|
<p className="text-lg text-gray-600 mb-4 font-medium">
|
||||||
</p>
|
Dirigé par <span className="text-brand-600">{featuredBusiness.founderName}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6">
|
<div className="flex flex-wrap gap-4 text-sm text-gray-500 mb-6">
|
||||||
<div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>
|
<div className="flex items-center"><Briefcase className="w-4 h-4 mr-1"/> {featuredBusiness.category}</div>
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ function makePrisma() {
|
|||||||
return new PrismaClient({ adapter })
|
return new PrismaClient({ adapter })
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as { prisma_v2: PrismaClient }
|
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
|
||||||
|
|
||||||
// Updated: 2026-05-10T17:21 (Forced reload for OneShotPlan model)
|
// Updated: 2026-05-12 (Forced reload for isHomeFeatured model)
|
||||||
export const prisma = globalForPrisma.prisma_v2 || makePrisma()
|
export const prisma = globalForPrisma.prisma_v3 || makePrisma()
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v2 = prisma
|
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma_v3 = prisma
|
||||||
|
|
||||||
export default prisma
|
export default prisma
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -2471,7 +2471,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -6376,7 +6376,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
@@ -6830,7 +6829,6 @@
|
|||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||||
@@ -7139,7 +7137,6 @@
|
|||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -7672,7 +7669,6 @@
|
|||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.4.0",
|
"loose-envify": "^1.4.0",
|
||||||
@@ -7684,7 +7680,6 @@
|
|||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ model Business {
|
|||||||
ratingCount Int @default(0)
|
ratingCount Int @default(0)
|
||||||
tags String[]
|
tags String[]
|
||||||
isFeatured Boolean @default(false)
|
isFeatured Boolean @default(false)
|
||||||
|
isHomeFeatured Boolean @default(false)
|
||||||
founderName String?
|
founderName String?
|
||||||
founderImageUrl String?
|
founderImageUrl String?
|
||||||
keyMetric String?
|
keyMetric String?
|
||||||
|
|||||||
Reference in New Issue
Block a user