feat: implement core platform features including business directory, admin dashboard, authentication, and API infrastructure
Some checks failed
Build and Push App / build (push) Failing after 16m2s

This commit is contained in:
2026-04-18 22:10:19 +02:00
parent 6ec1a3ae84
commit 3e2063e4fa
89 changed files with 4405 additions and 967 deletions

View File

@@ -61,3 +61,21 @@ export async function removeFeaturedBusiness(id: string) {
return { success: false, error: "Erreur lors de la suppression" };
}
}
export async function getPendingVerificationCount() {
try {
return await prisma.business.count({
where: {
isActive: true,
verified: false,
isSuspended: false,
plan: {
in: ['BOOSTER', 'EMPIRE']
}
}
});
} catch (error) {
console.error("Failed to get pending count:", error);
return 0;
}
}

View File

@@ -0,0 +1,60 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getCountries() {
try {
return await prisma.country.findMany({
orderBy: { name: 'asc' }
});
} catch (error) {
console.error('Error fetching countries:', error);
return [];
}
}
export async function addCountry(data: { name: string, code: string, flag?: string }) {
try {
const country = await prisma.country.create({
data: {
name: data.name,
code: data.code.toUpperCase(),
flag: data.flag || null,
isActive: true
}
});
revalidatePath('/countries');
return { success: true, country };
} catch (error) {
console.error('Error adding country:', error);
return { success: false, error: 'Ce pays ou code existe déjà.' };
}
}
export async function deleteCountry(id: string) {
try {
await prisma.country.delete({
where: { id }
});
revalidatePath('/countries');
return { success: true };
} catch (error) {
console.error('Error deleting country:', error);
return { success: false, error: 'Erreur lors de la suppression.' };
}
}
export async function toggleCountryStatus(id: string, isActive: boolean) {
try {
await prisma.country.update({
where: { id },
data: { isActive }
});
revalidatePath('/countries');
return { success: true };
} catch (error) {
console.error('Error toggling country status:', error);
return { success: false };
}
}

View File

@@ -0,0 +1,31 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getLegalDocument(type: 'CGU' | 'CGV') {
try {
return await prisma.legalDocument.findUnique({
where: { type }
});
} catch (error) {
console.error(`Error fetching ${type}:`, error);
return null;
}
}
export async function updateLegalDocument(type: 'CGU' | 'CGV', title: string, content: string) {
try {
const doc = await prisma.legalDocument.upsert({
where: { type },
update: { title, content },
create: { type, title, content }
});
revalidatePath('/legal');
return { success: true, doc };
} catch (error) {
console.error(`Error updating ${type}:`, error);
return { success: false, error: "Erreur lors de la mise à jour" };
}
}

View File

@@ -50,3 +50,31 @@ export async function updateReportStatus(id: string, status: 'RESOLVED' | 'DISMI
return { success: false, error: "Erreur lors de la mise à jour" };
}
}
export async function getPendingRatings() {
return await prisma.rating.findMany({
where: { status: 'PENDING' },
include: {
user: {
select: { id: true, name: true, email: true }
},
business: {
select: { id: true, name: true }
}
},
orderBy: { createdAt: 'desc' }
});
}
export async function updateRatingStatus(id: string, status: 'APPROVED' | 'REJECTED') {
try {
await prisma.rating.update({
where: { id },
data: { status }
});
revalidatePath('/moderation');
return { success: true };
} catch (error) {
return { success: false, error: "Erreur lors de la mise à jour de l'avis" };
}
}

View File

@@ -0,0 +1,54 @@
"use server";
import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';
export async function getPricingPlans() {
try {
return await prisma.pricingPlan.findMany({
orderBy: { offerLimit: 'asc' }
});
} catch (error) {
console.error('Error fetching pricing plans:', error);
return [];
}
}
export async function updatePricingPlanDetail(id: string, data: any) {
try {
await prisma.pricingPlan.update({
where: { id },
data: {
name: data.name,
priceXOF: data.priceXOF,
priceEUR: data.priceEUR,
description: data.description,
features: data.features, // Expected to be string[]
offerLimit: parseInt(data.offerLimit),
recommended: !!data.recommended,
color: data.color
}
});
revalidatePath('/plans');
return { success: true };
} catch (error) {
console.error('Error updating pricing plan detail:', error);
return { success: false, error: 'Erreur lors de la mise à jour des détails du forfait.' };
}
}
export async function updateBusinessPlan(id: string, plan: 'STARTER' | 'BOOSTER' | 'EMPIRE') {
try {
await prisma.business.update({
where: { id },
data: { plan }
});
revalidatePath('/entrepreneurs');
return { success: true };
} catch (error) {
console.error('Error updating business plan:', error);
return { success: false, error: 'Erreur lors de la mise à jour du forfait.' };
}
}

View File

@@ -11,16 +11,16 @@ export async function getSiteSettings() {
// Default fallback values if not initialized
const defaultSettings = {
siteName: "Afropreunariat",
siteName: "Afrohub",
siteSlogan: "La plateforme de référence pour l'entrepreneuriat africain.",
contactEmail: "support@afropreunariat.com",
contactEmail: "support@afrohub.com",
contactPhone: "+225 00 00 00 00 00",
address: "Abidjan, Côte d'Ivoire",
facebookUrl: "",
twitterUrl: "",
instagramUrl: "",
linkedinUrl: "",
footerText: "© 2025 Afropreunariat. Tous droits réservés."
footerText: "© 2025 Afrohub. Tous droits réservés."
};
if (!settings) return defaultSettings;

View File

@@ -0,0 +1,159 @@
"use client";
import React, { useState, useEffect, useTransition } from 'react';
import { getCountries, addCountry, deleteCountry, toggleCountryStatus } from '@/app/actions/countries';
import { Globe, Plus, Trash2, MapPin, Search, Loader2, Flag } from 'lucide-react';
import { toast } from 'react-hot-toast';
export default function CountriesPage() {
const [countries, setCountries] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isAdding, setIsAdding] = useState(false);
const [isPending, startTransition] = useTransition();
const loadData = async () => {
const data = await getCountries();
setCountries(data);
setLoading(false);
};
useEffect(() => {
loadData();
}, []);
const handleAdd = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const name = formData.get('name') as string;
const code = formData.get('code') as string;
const flag = formData.get('flag') as string;
startTransition(async () => {
const result = await addCountry({ name, code, flag });
if (result.success) {
toast.success("Pays ajouté avec succès");
setIsAdding(false);
loadData();
} else {
toast.error(result.error || "Erreur lors de l'ajout");
}
});
};
const handleDelete = async (id: string, name: string) => {
if (!confirm(`Supprimer le pays "${name}" ? Cela pourrait affecter les entreprises liées.`)) return;
const result = await deleteCountry(id);
if (result.success) {
toast.success("Pays supprimé");
loadData();
} else {
toast.error("Erreur lors de la suppression");
}
};
const handleToggleStatus = async (id: string, current: boolean) => {
const result = await toggleCountryStatus(id, !current);
if (result.success) {
loadData();
}
};
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8">
<div className="flex justify-between items-end mb-8">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Pays</h1>
<p className="text-slate-400">Gérez les pays disponibles pour les entreprises et le filtrage du répertoire.</p>
</div>
<button
onClick={() => setIsAdding(!isAdding)}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-xl font-bold transition-all shadow-lg flex items-center gap-2"
>
<Plus className="w-5 h-5" /> Ajouter un pays
</button>
</div>
{isAdding && (
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl p-6 mb-8 animate-in fade-in slide-in-from-top-2">
<h2 className="text-lg font-bold text-white mb-4">Nouveau Pays</h2>
<form onSubmit={handleAdd} className="grid grid-cols-1 md:grid-cols-4 gap-4 items-end">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Nom du pays</label>
<input name="name" required placeholder="Ex: Sénégal" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Code ISO (2 lettres)</label>
<input name="code" required maxLength={2} placeholder="Ex: SN" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 uppercase" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400">Emoji Drapeau / URL Icone</label>
<input name="flag" placeholder="Ex: 🇸🇳" className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500" />
</div>
<div className="flex gap-2">
<button disabled={isPending} type="submit" className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white p-3 rounded-xl font-bold transition-all disabled:opacity-50">
{isPending ? 'Chargement...' : 'Enregistrer'}
</button>
<button type="button" onClick={() => setIsAdding(false)} className="bg-slate-800 hover:bg-slate-700 text-white p-3 rounded-xl font-bold transition-all">
Annuler
</button>
</div>
</form>
</div>
)}
<div className="bg-slate-900/50 border border-slate-700 rounded-2xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-800/50 text-slate-400 uppercase text-xs font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Pays</th>
<th className="px-6 py-4">Code</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{countries.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-slate-500">Aucun pays configuré.</td>
</tr>
) : countries.map((country) => (
<tr key={country.id} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{country.flag || <MapPin className="w-5 h-5 text-slate-600" />}</span>
<span className="font-bold text-white">{country.name}</span>
</div>
</td>
<td className="px-6 py-4 font-mono text-indigo-400">{country.code}</td>
<td className="px-6 py-4">
<button
onClick={() => handleToggleStatus(country.id, country.isActive)}
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold ${
country.isActive ? 'bg-emerald-500/10 text-emerald-500' : 'bg-slate-500/10 text-slate-500'
}`}
>
{country.isActive ? 'Actif' : 'Inactif'}
</button>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => handleDelete(country.id, country.name)} className="p-2 text-slate-500 hover:text-red-500 transition-colors">
<Trash2 className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -21,7 +21,13 @@ async function getStats() {
});
const totalViews = aggregateResult._sum.viewCount || 0;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews };
// Unique Visitors (by IP)
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
by: ['ip'],
});
const uniqueVisitors = uniqueVisitorsRes.length;
return { usersCount, businessCount, pendingCount, commentsCount, totalViews, uniqueVisitors };
}
export default async function DashboardPage() {
@@ -29,6 +35,7 @@ export default async function DashboardPage() {
const statCards = [
{ label: 'Utilisateurs', value: stats.usersCount, icon: Users, color: 'text-blue-400' },
{ label: 'Visiteurs Uniques', value: stats.uniqueVisitors, icon: Eye, color: 'text-indigo-400' },
{ label: 'Entrepreneurs', value: stats.businessCount, icon: Store, color: 'text-emerald-400' },
{ label: 'En attente', value: stats.pendingCount, icon: Clock, color: 'text-amber-400' },
{ label: 'Commentaires', value: stats.commentsCount, icon: MessageCircle, color: 'text-purple-400' },

View File

@@ -2,7 +2,8 @@ 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';
import PlanSelector from '@/components/PlanSelector';
import { ShieldAlert, ShieldX, ShieldCheck } from 'lucide-react';
async function getBusinesses() {
return await prisma.business.findMany({
@@ -20,12 +21,31 @@ async function getBusinesses() {
export default async function EntrepreneursPage() {
const businesses = await getBusinesses();
const pendingCount = businesses.filter((b: any) =>
!b.verified &&
!b.isSuspended &&
['BOOSTER', 'EMPIRE'].includes(b.plan)
).length;
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
<p className="text-slate-400">Valider ou révoquer les comptes des entreprises.</p>
<div className="mb-8 flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Entrepreneurs</h1>
<p className="text-slate-400">Gérer les abonnements et certifier les comptes (Badge Bleu).</p>
</div>
{pendingCount > 0 && (
<div className="bg-blue-500/10 border border-blue-500/50 px-6 py-3 rounded-2xl flex items-center gap-4 animate-pulse">
<div className="w-10 h-10 bg-blue-500 rounded-xl flex items-center justify-center text-white">
<ShieldCheck className="w-6 h-6" />
</div>
<div>
<div className="text-blue-400 font-bold">{pendingCount} en attente</div>
<div className="text-xs text-blue-400/80 font-medium uppercase tracking-wider">Certifications Booster</div>
</div>
</div>
)}
</div>
<div className="card overflow-hidden p-0">
@@ -36,6 +56,7 @@ export default async function EntrepreneursPage() {
<th>Catégorie</th>
<th>Propriétaire</th>
<th>Statut</th>
<th className="text-center">Forfait</th>
<th className="text-center">Visibilité</th>
<th className="text-center">Mise en avant</th>
<th className="text-right">Action</th>
@@ -90,6 +111,9 @@ export default async function EntrepreneursPage() {
)}
</div>
</td>
<td className="text-center">
<PlanSelector businessId={business.id} currentPlan={business.plan} />
</td>
<td className="text-center font-medium text-slate-300">
{business.viewCount.toLocaleString()}
</td>

View File

@@ -7,8 +7,8 @@ import { Toaster } from 'react-hot-toast';
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AfroAdmin - Administration Afropreunariat",
description: "Panneau d'administration pour la plateforme Afropreunariat",
title: "AfroAdmin - Administration Afrohub",
description: "Panneau d'administration pour la plateforme Afrohub",
};
export default function RootLayout({

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { getLegalDocument } from '@/app/actions/legal';
import LegalEditor from '@/components/LegalEditor';
export const metadata = {
title: 'Documents Légaux - AfroAdmin',
description: 'Gérez les CGU et CGV de la plateforme.',
};
export default async function LegalPage() {
const cgu = await getLegalDocument('CGU');
const cgv = await getLegalDocument('CGV');
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">Documents Légaux</h1>
<p className="text-slate-400">Modifiez les conditions générales d'utilisation et de vente du site.</p>
</div>
<LegalEditor initialCgu={cgu} initialCgv={cgv} />
</div>
);
}

View File

@@ -1,125 +1,341 @@
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import {
BarChart3,
MousePointer2,
Clock,
Eye,
ArrowUpRight,
TrendingUp
TrendingUp,
Globe,
Users,
Zap,
Search,
ArrowUp,
ArrowDown,
Calendar
} from 'lucide-react';
async function getMetrics() {
// 1. Top Pages
const topPages = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'PAGE_VIEW' },
_count: {
id: true
},
orderBy: {
_count: {
id: 'desc'
}
},
take: 10
});
function ComparisonBadge({ value, isPercent = true }: { value: number | null, isPercent?: boolean }) {
if (value === null) return null;
const isPositive = value > 0;
const isZero = Math.abs(value) < 0.1;
// 2. Top Clicks
const topClicks = await prisma.analyticsEvent.groupBy({
by: ['label'],
where: { type: 'CLICK' },
_count: {
id: true
},
orderBy: {
_count: {
id: 'desc'
}
},
take: 10
});
if (isZero) return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400">
= 0%
</span>
);
// 3. Average Dwell Time
const dwellTime = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'DWELL_TIME' },
_avg: {
value: true
},
_count: {
id: true
},
orderBy: {
_avg: {
value: 'desc'
}
},
take: 10
});
// 4. Global Stats
const totalEvents = await prisma.analyticsEvent.count();
const uniquePaths = (await prisma.analyticsEvent.groupBy({ by: ['path'] })).length;
return { topPages, topClicks, dwellTime, totalEvents, uniquePaths };
return (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold ${
isPositive ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'
}`}>
{isPositive ? <ArrowUp className="w-2 h-2 mr-0.5" /> : <ArrowDown className="w-2 h-2 mr-0.5" />}
{Math.abs(value).toFixed(1)}{isPercent ? '%' : ''}
</span>
);
}
export default async function MetricsPage() {
const { topPages, topClicks, dwellTime, totalEvents, uniquePaths } = await getMetrics();
async function getMetrics(range: string = 'week', from?: string, to?: string) {
let startDate: Date;
let endDate: Date = new Date();
const now = new Date();
// 1. Determine Current Range
if (from && to) {
startDate = new Date(from);
endDate = new Date(to);
endDate.setHours(23, 59, 59, 999);
} else {
if (range === 'day') startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000);
else if (range === 'month') startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
else if (range === 'all') startDate = new Date(0); // All time
else startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // Default Week
}
const currentWhere = { createdAt: { gte: startDate, lte: endDate } };
// 2. Determine Previous Range (for comparison)
let prevWhere: any = null;
if (range !== 'all') {
const duration = endDate.getTime() - startDate.getTime();
const prevEndDate = new Date(startDate.getTime() - 1);
const prevStartDate = new Date(startDate.getTime() - duration);
prevWhere = { createdAt: { gte: prevStartDate, lte: prevEndDate } };
}
// 3. Fetch Current Data
const topPages = await prisma.analyticsEvent.groupBy({
by: ['path'],
where: { type: 'PAGE_VIEW', ...currentWhere },
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10
});
const topCountries = await prisma.analyticsEvent.groupBy({
by: ['country'],
where: currentWhere,
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 10
});
const devices = await prisma.analyticsEvent.groupBy({
by: ['device'],
where: currentWhere,
_count: { id: true },
orderBy: { _count: { id: 'desc' } }
});
const browsers = await prisma.analyticsEvent.groupBy({
by: ['browser'],
where: currentWhere,
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: 5
});
const totalEvents = await prisma.analyticsEvent.count({ where: currentWhere });
const uniqueVisitorsRes = await prisma.analyticsEvent.groupBy({
by: ['ip'],
where: currentWhere
});
const uniqueVisitors = uniqueVisitorsRes.length;
// 4. Fetch Previous Data (for Deltas)
let prevTotalEvents = 0;
let prevUniqueVisitors = 0;
let prevMobileCount = 0;
if (prevWhere) {
prevTotalEvents = await prisma.analyticsEvent.count({ where: prevWhere });
const prevVisitorsRes = await prisma.analyticsEvent.groupBy({
by: ['ip'],
where: prevWhere
});
prevUniqueVisitors = prevVisitorsRes.length;
const prevDevices = await prisma.analyticsEvent.groupBy({
by: ['device'],
where: prevWhere,
_count: { id: true }
});
prevMobileCount = prevDevices.find(d => d.device === 'Mobile')?._count.id || 0;
}
const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0;
return {
topPages,
topCountries,
devices,
browsers,
totalEvents,
uniqueVisitors,
deltas: {
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
mobile: prevTotalEvents > 0 && totalEvents > 0
? ((currentMobileCount / totalEvents) - (prevMobileCount / prevTotalEvents)) * 100
: null
}
};
}
export default async function MetricsPage({ searchParams: searchParamsPromise }: { searchParams: Promise<{ range?: string, from?: string, to?: string }> }) {
const searchParams = await searchParamsPromise;
const range = searchParams.range || 'week';
const { topPages, topCountries, devices, browsers, totalEvents, uniqueVisitors, deltas } = await getMetrics(range, searchParams.from, searchParams.to);
const rangeLabels: Record<string, string> = {
day: 'Dernières 24h',
week: '7 derniers jours',
month: '30 derniers jours',
all: 'Depuis le début'
};
return (
<div className="space-y-8">
<div className="flex justify-between items-end">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Metrics & Analytics</h1>
<p className="text-slate-400">Comportement des utilisateurs et engagement en temps réel.</p>
<p className="text-slate-400">Comportement des utilisateurs et engagement géographique en temps réel.</p>
</div>
<div className="bg-slate-800/50 border border-slate-700 px-4 py-2 rounded-lg text-xs font-mono text-slate-400">
Capture : <span className="text-emerald-400">{totalEvents.toLocaleString()}</span> événements total
</div>
</div>
{/* Controls: Shortcuts + Custom Calendar */}
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
{Object.entries(rangeLabels).map(([id, label]) => (
<Link
key={id}
href={`/metrics?range=${id}`}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
range === id && !searchParams.from
? 'bg-indigo-600 text-white shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
{label}
</Link>
))}
</div>
<form action="/metrics" className="flex items-center gap-2 bg-slate-900/50 p-2 rounded-xl border border-slate-800">
<Calendar className="w-4 h-4 text-slate-500 ml-2" />
<input
type="date"
name="from"
defaultValue={searchParams.from}
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
/>
<span className="text-slate-600"></span>
<input
type="date"
name="to"
defaultValue={searchParams.to}
className="bg-transparent border-none text-xs text-white focus:ring-0 cursor-pointer"
/>
<button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white text-[10px] uppercase font-bold px-3 py-1.5 rounded-lg transition-colors">
Filtrer
</button>
</form>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="card">
<div className="card border-t-4 border-indigo-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-400">
<Users className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{uniqueVisitors}</div>
<ComparisonBadge value={deltas.visitors} />
</div>
</div>
<h3 className="text-slate-400 text-sm font-medium">Visiteurs uniques (IP)</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
</p>
</div>
<div className="card border-t-4 border-emerald-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-400">
<Eye className="w-6 h-6" />
</div>
<span className="text-2xl font-bold text-white">{uniquePaths}</span>
</div>
<h3 className="text-slate-400 font-medium">Pages uniques explorées</h3>
</div>
<div className="card">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-pink-500/10 text-pink-400">
<MousePointer2 className="w-6 h-6" />
<div className="text-right">
<div className="text-2xl font-bold text-white">{totalEvents}</div>
<ComparisonBadge value={deltas.events} />
</div>
<span className="text-2xl font-bold text-white">
{topClicks.reduce((acc, curr) => acc + curr._count.id, 0)}
</span>
</div>
<h3 className="text-slate-400 font-medium">Clicks enregistrés (Top 10)</h3>
<h3 className="text-slate-400 text-sm font-medium">Événements enregistrés</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
</p>
</div>
<div className="card">
<div className="card border-t-4 border-amber-500">
<div className="flex justify-between items-start mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="w-6 h-6" />
<Zap className="w-6 h-6" />
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">
{totalEvents > 0 ? Math.round((devices.find(d => d.device === 'Mobile')?._count.id || 0) / totalEvents * 100) : 0}%
</div>
<ComparisonBadge value={deltas.mobile} />
</div>
<span className="text-2xl font-bold text-white">
{Math.round(dwellTime.reduce((acc, curr) => acc + (curr._avg.value || 0), 0) / (dwellTime.length || 1))}s
</span>
</div>
<h3 className="text-slate-400 font-medium">Temps moyen / page</h3>
<h3 className="text-slate-400 text-sm font-medium">Part du trafic Mobile</h3>
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
{searchParams.from ? `Du ${searchParams.from} au ${searchParams.to}` : rangeLabels[range]}
</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Pages Table */}
{/* Top Countries Table */}
<div className="card p-0 overflow-hidden">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-400" /> Pages les plus visitées
<Globe className="w-5 h-5 text-indigo-400" /> TOP 10 - Géographie
</h2>
</div>
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Pays</th>
<th className="px-6 py-4 text-right">Visites</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topCountries.map((c) => (
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 font-bold">{c.country || 'Inconnu'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{c._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Device & Browser Stats */}
<div className="space-y-8">
<div className="card p-0 overflow-hidden">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-400" /> Appareils
</h2>
</div>
<div className="p-6 space-y-4">
{devices.map(d => (
<div key={d.device} className="space-y-1">
<div className="flex justify-between text-xs text-slate-400 uppercase font-bold">
<span>{d.device || 'Autre'}</span>
<span>{Math.round(d._count.id / totalEvents * 100)}%</span>
</div>
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
<div
className="bg-indigo-500 h-full transition-all duration-500"
style={{ width: `${(d._count.id / totalEvents * 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
<div className="card p-0 overflow-hidden">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Search className="w-5 h-5 text-emerald-400" /> Navigateurs
</h2>
</div>
<div className="p-6 grid grid-cols-2 gap-4">
{browsers.map(b => (
<div key={b.browser} className="bg-slate-950/50 border border-slate-800 p-4 rounded-xl text-center">
<div className="text-2xl font-bold text-white mb-1">{b._count.id}</div>
<div className="text-[10px] text-slate-500 uppercase font-bold tracking-widest">{b.browser || 'Autre'}</div>
</div>
))}
</div>
</div>
</div>
{/* Top Pages Table (Moved and compact) */}
<div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-rose-400" /> Pages les plus visitées
</h2>
</div>
<table className="w-full text-left">
@@ -141,64 +357,6 @@ export default async function MetricsPage() {
</tbody>
</table>
</div>
{/* Top Clicks Table */}
<div className="card p-0 overflow-hidden">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<MousePointer2 className="w-5 h-5 text-indigo-400" /> Actions & Clicks
</h2>
</div>
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Élément / Texte</th>
<th className="px-6 py-4 text-right">Interactions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{topClicks.map((click) => (
<tr key={click.label} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm text-slate-300 truncate max-w-xs">{click.label || 'Sans label'}</td>
<td className="px-6 py-4 text-right text-sm font-bold text-white">
{click._count.id}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Dwell Time Table */}
<div className="card p-0 overflow-hidden lg:col-span-2">
<div className="p-6 border-b border-slate-800 flex items-center justify-between">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-400" /> Temps d'attention par page
</h2>
</div>
<table className="w-full text-left">
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<tr>
<th className="px-6 py-4">Page</th>
<th className="px-6 py-4">Échantillon</th>
<th className="px-6 py-4 text-right">Moyenne de rétention</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{dwellTime.map((time) => (
<tr key={time.path} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4 text-sm font-mono text-slate-300">{time.path}</td>
<td className="px-6 py-4 text-xs text-slate-500">{time._count.id} sessions</td>
<td className="px-6 py-4 text-right">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-500/10 text-amber-400 border border-amber-500/20">
{Math.round(time._avg.value || 0)} secondes
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);

View File

@@ -1,133 +1,179 @@
import { getReports } from '@/app/actions/moderation';
import { getReports, getPendingRatings } from '@/app/actions/moderation';
import ReportActionButtons from '@/components/ReportActionButtons';
import ModerationSuspensionButton from '@/components/ModerationSuspensionButton';
import { Flag, MessageCircle, User, Calendar } from 'lucide-react';
import RatingModerationButtons from '@/components/RatingModerationButtons';
import { Flag, MessageCircle, User, Calendar, Star, CheckCircle } from 'lucide-react';
import Link from 'next/link';
export default async function ModerationPage() {
export default async function ModerationPage({
searchParams
}: {
searchParams: Promise<{ tab?: string }>
}) {
const { tab = 'messages' } = await searchParams;
const reports = await getReports();
const pendingRatings = await getPendingRatings();
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
Centre de Modération
</h1>
<p className="text-slate-400">Gérer les signalements de contenus inappropriés dans les discussions.</p>
<p className="text-slate-400">Gérer les signalements et valider les nouveaux avis.</p>
</div>
{/* Tabs */}
<div className="flex gap-2 mb-8 bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<Link
href="/moderation?tab=messages"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'messages' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<MessageCircle className="w-4 h-4" />
Messages ({reports.filter(r => r.status === 'PENDING').length})
</Link>
<Link
href="/moderation?tab=avis"
className={`px-6 py-2 rounded-lg text-sm font-bold transition-all flex items-center gap-2 ${tab === 'avis' ? 'bg-brand-600 text-white shadow-lg' : 'text-slate-400 hover:text-white hover:bg-slate-800'}`}
>
<Star className="w-4 h-4" />
Avis ({pendingRatings.length})
</Link>
</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>
{tab === 'messages' && (
<>
{reports.length === 0 ? (
<div className="card p-10 text-center text-slate-500">
Aucun signalement en attente.
</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é
) : (
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">
<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 uppercase">{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</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' : 'badge-verified'
}`}>
{report.status === 'PENDING' ? 'En attente' : 'Terminé'}
</span>
</div>
</div>
<div className="flex-1 bg-slate-900/40 rounded-xl border border-slate-800 p-4">
<Link
href={`/messages/${report.message.conversationId}`}
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 mb-4"
>
Voir la conversation complète
</Link>
<div className="p-3 bg-slate-800/80 rounded-lg border border-red-500/20 mb-4">
<div className="text-[10px] text-red-400 font-bold uppercase mb-1">Message Signalé</div>
<div className="text-sm text-white">"{report.message.content}"</div>
<div className="text-[10px] text-slate-500 mt-1">Auteur: {report.message.sender.name}</div>
</div>
{report.status === 'PENDING' && (
<div className="flex flex-col gap-4 mt-6 pt-6 border-t border-slate-700">
<ReportActionButtons reportId={report.id} />
<ModerationSuspensionButton
userId={report.message.senderId}
userName={report.message.sender.name}
isSuspended={!!report.message.sender.isSuspended}
/>
</div>
)}
</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>
))
))
)}
</>
)}
{tab === 'avis' && (
<>
{pendingRatings.length === 0 ? (
<div className="card p-20 text-center">
<CheckCircle className="w-12 h-12 text-green-500/20 mx-auto mb-4" />
<h3 className="text-lg font-bold text-white mb-1">Tous les avis sont modérés</h3>
<p className="text-slate-500 text-sm">Beau travail ! Il n'y a plus aucun avis en attente.</p>
</div>
) : (
pendingRatings.map((r) => (
<div key={r.id} className="card border-l-4 border-amber-500 animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="flex flex-col lg:flex-row gap-8">
<div className="lg:w-1/4 space-y-5">
<div className="flex items-center gap-2 text-slate-400 text-xs">
<Calendar className="w-3.5 h-3.5" />
{new Date(r.createdAt).toLocaleString('fr-FR')}
</div>
<div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Auteur</div>
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center text-brand-400 font-bold text-sm uppercase">{r.user.name.charAt(0)}</div>
<div>
<div className="text-sm font-bold text-white">{r.user.name}</div>
<div className="text-[10px] text-slate-500">{r.user.email}</div>
</div>
</div>
</div>
<div>
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1.5">Entrepreneur Concerné</div>
<div className="bg-slate-900/80 p-2.5 rounded-lg border border-slate-800">
<div className="text-sm font-bold text-indigo-400">{r.business.name}</div>
</div>
</div>
</div>
<div className="flex-1">
<div className="flex flex-col h-full">
<div className="flex-1 bg-slate-900/60 p-5 rounded-2xl border border-slate-800 relative">
<div className="flex text-yellow-500 mb-3">
{[1,2,3,4,5].map(s => (
<Star key={s} className={`w-4 h-4 ${s <= r.value ? 'fill-current' : 'text-slate-800'}`} />
))}
</div>
<p className="text-slate-200 text-sm leading-relaxed italic">
{r.comment || "Pas de commentaire."}
</p>
</div>
<div className="mt-6 pt-6 border-t border-slate-800 flex items-center justify-between">
<div className="text-[10px] text-slate-500 flex items-center gap-1">
<Flag className="w-3 h-3 text-amber-500" />
En attente de validation
</div>
<RatingModerationButtons ratingId={r.id} />
</div>
</div>
</div>
</div>
</div>
))
)}
</>
)}
</div>
</div>

View File

@@ -0,0 +1,103 @@
"use client";
import React, { useState, useEffect } from 'react';
import { getPricingPlans } from '@/app/actions/plans';
import { CreditCard, Edit3, CheckCircle2, Star, Loader2, Zap } from 'lucide-react';
import PlanEditModal from '@/components/PlanEditModal';
export default function PlansPage() {
const [plans, setPlans] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingPlan, setEditingPlan] = useState<any>(null);
useEffect(() => {
async function loadPlans() {
const data = await getPricingPlans();
setPlans(data);
setLoading(false);
}
loadPlans();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<Loader2 className="w-8 h-8 animate-spin text-indigo-500" />
</div>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">Gestion des Tarifs & Forfaits</h1>
<p className="text-slate-400">Modifiez les noms, prix, limites d'offres et avantages affichés sur le site principal.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{plans.map((plan) => (
<div key={plan.id} className={`bg-slate-900 border ${plan.recommended ? 'border-indigo-500 ring-1 ring-indigo-500/50' : 'border-slate-800'} rounded-2xl overflow-hidden flex flex-col group transition-all hover:shadow-2xl hover:shadow-indigo-500/10`}>
{/* Header */}
<div className="p-6 bg-slate-800/50 border-b border-slate-800">
<div className="flex justify-between items-start mb-4">
<div className={`p-3 rounded-xl ${
plan.tier === 'STARTER' ? 'bg-slate-700/50 text-slate-400' :
plan.tier === 'BOOSTER' ? 'bg-indigo-500/20 text-indigo-400' :
'bg-amber-500/20 text-amber-400'
}`}>
{plan.tier === 'STARTER' ? <Zap className="w-5 h-5" /> :
plan.tier === 'BOOSTER' ? <Zap className="w-5 h-5" /> :
<Star className="w-5 h-5" />}
</div>
{plan.recommended && (
<span className="bg-indigo-600 text-[10px] font-bold uppercase tracking-widest px-2 py-1 rounded text-white shadow-lg">
Populaire
</span>
)}
</div>
<h3 className="text-xl font-bold text-white mb-1 uppercase tracking-wider">{plan.name}</h3>
<p className="text-sm text-slate-400 line-clamp-1">{plan.description}</p>
</div>
{/* Price & Limit */}
<div className="p-6 bg-slate-950/30 flex-1">
<div className="mb-6">
<div className="text-3xl font-bold text-white mb-1">{plan.priceXOF}</div>
<div className="text-xs text-slate-500 uppercase font-bold tracking-tighter">Limite : {plan.offerLimit} Offre(s)</div>
</div>
<div className="space-y-3 mb-8">
<div className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Avantages inclus :</div>
{plan.features.map((feature: string, idx: number) => (
<div key={idx} className="flex items-start gap-2 text-sm text-slate-300">
<CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />
<span>{feature}</span>
</div>
))}
</div>
</div>
{/* Action */}
<div className="p-4 bg-slate-800/20 border-t border-slate-800">
<button
onClick={() => setEditingPlan(plan)}
className="w-full py-3 rounded-xl bg-slate-800 hover:bg-slate-700 text-white font-bold transition-all flex items-center justify-center gap-2 uppercase tracking-widest text-xs border border-slate-700"
>
<Edit3 className="w-4 h-4" />
Modifier le plan
</button>
</div>
</div>
))}
</div>
{editingPlan && (
<PlanEditModal
isOpen={!!editingPlan}
onClose={() => setEditingPlan(null)}
plan={editingPlan}
/>
)}
</div>
);
}

View File

@@ -40,7 +40,7 @@ export default function FeaturedModal({ business }: Props) {
});
};
const handleRemove = () => {
const handleRemove = async () => {
if (confirm("Retirer cet entrepreneur du titre 'Entrepreneur du mois' ?")) {
startTransition(async () => {
const result = await removeFeaturedBusiness(business.id);
@@ -48,7 +48,7 @@ export default function FeaturedModal({ business }: Props) {
toast.success("Titre révoqué");
setIsOpen(false);
} else {
toast.error(result.error);
toast.error(result.error || "Une erreur est survenue lors de la suppression");
}
});
}

View File

@@ -0,0 +1,131 @@
"use client";
import React, { useState } from 'react';
import { LegalDocument } from '@prisma/client';
import RichTextEditor from './RichTextEditor';
import { updateLegalDocument } from '@/app/actions/legal';
import { toast } from 'react-hot-toast';
import { FileText, Save, Loader2 } from 'lucide-react';
interface Props {
initialCgu: LegalDocument | null;
initialCgv: LegalDocument | null;
}
export default function LegalEditor({ initialCgu, initialCgv }: Props) {
const [activeTab, setActiveTab] = useState<'CGU' | 'CGV'>('CGU');
const [cgu, setCgu] = useState({
title: initialCgu?.title || "Conditions Générales d'Utilisation",
content: initialCgu?.content || ""
});
const [cgv, setCgv] = useState({
title: initialCgv?.title || "Conditions Générales de Vente",
content: initialCgv?.content || ""
});
const [isSaving, setIsSaving] = useState(false);
const currentDoc = activeTab === 'CGU' ? cgu : cgv;
const handleUpdate = (field: string, value: string) => {
if (activeTab === 'CGU') {
setCgu(prev => ({ ...prev, [field]: value }));
} else {
setCgv(prev => ({ ...prev, [field]: value }));
}
};
const handleSave = async () => {
setIsSaving(true);
try {
const result = await updateLegalDocument(activeTab, currentDoc.title, currentDoc.content);
if (result.success) {
toast.success(`${activeTab} mis à jour avec succès`);
} else {
toast.error(result.error || "Une erreur est survenue");
}
} catch (error) {
toast.error("Erreur de connexion au serveur");
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex bg-slate-900/50 p-1 rounded-xl border border-slate-800 w-fit">
<button
onClick={() => setActiveTab('CGU')}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === 'CGU'
? 'bg-indigo-600 text-white shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
CGU
</button>
<button
onClick={() => setActiveTab('CGV')}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-all ${
activeTab === 'CGV'
? 'bg-indigo-600 text-white shadow-lg'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
CGV
</button>
</div>
<div className="card">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-600/20 text-indigo-400 rounded-xl flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Modifier {activeTab}</h2>
<p className="text-sm text-slate-400">Dernière mise à jour : {new Date().toLocaleDateString('fr-FR')}</p>
</div>
</div>
<button
onClick={handleSave}
disabled={isSaving}
className="btn-primary flex items-center gap-2"
>
{isSaving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Enregistrer les modifications
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5 ml-1">Titre du document</label>
<input
type="text"
value={currentDoc.title}
onChange={(e) => handleUpdate('title', e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded-xl px-4 py-3 text-white focus:ring-2 focus:ring-indigo-500 outline-none transition-all"
placeholder="Ex: Conditions Générales d'Utilisation"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-400 mb-1.5 ml-1">Contenu</label>
<RichTextEditor
value={currentDoc.content}
onChange={(val) => handleUpdate('content', val)}
placeholder={`Commencez à rédiger vos ${activeTab}...`}
/>
</div>
</div>
</div>
<div className="bg-amber-500/10 border border-amber-500/30 p-4 rounded-xl flex items-center gap-3 text-amber-500">
<div className="shrink-0 w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center">!</div>
<p className="text-sm font-medium">
Attention : Les modifications seront immédiatement visibles pour tous les utilisateurs sur le site public une fois enregistrées.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,217 @@
"use client";
import React, { useState } from 'react';
import { X, Plus, Trash2, Save, Loader2 } from 'lucide-react';
import { updatePricingPlanDetail } from '@/app/actions/plans';
import { toast } from 'react-hot-toast';
interface PlanEditModalProps {
isOpen: boolean;
onClose: () => void;
plan: any;
}
export default function PlanEditModal({ isOpen, onClose, plan }: PlanEditModalProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
name: plan.name,
priceXOF: plan.priceXOF,
priceEUR: plan.priceEUR,
description: plan.description,
offerLimit: plan.offerLimit.toString(),
recommended: plan.recommended,
color: plan.color,
});
const [features, setFeatures] = useState<string[]>(plan.features || []);
const handleAddFeature = () => {
setFeatures([...features, '']);
};
const handleFeatureChange = (index: number, value: string) => {
const newFeatures = [...features];
newFeatures[index] = value;
setFeatures(newFeatures);
};
const handleRemoveFeature = (index: number) => {
setFeatures(features.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try {
const result = await updatePricingPlanDetail(plan.id, {
...formData,
features: features.filter(f => f.trim() !== ''),
});
if (result.success) {
toast.success("Détails du forfait mis à jour !");
onClose();
window.location.reload(); // Refresh to catch changes
} else {
toast.error(result.error || "Erreur lors de la sauvegarde.");
}
} catch (error) {
toast.error("Erreur réseau");
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 sm:p-6">
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm transition-opacity" onClick={onClose}></div>
<div className="relative bg-slate-900 border border-slate-700 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col transition-all">
{/* Header */}
<div className="p-6 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
<div>
<h2 className="text-xl font-bold text-white font-serif">Modifier le plan {plan.tier}</h2>
<p className="text-sm text-slate-400">Configurez l'affichage et les limites de ce forfait.</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto flex-1">
<form id="plan-form" onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Nom affiché</label>
<input
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Limite d'offres</label>
<input
type="number"
value={formData.offerLimit}
onChange={(e) => setFormData({...formData, offerLimit: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix XOF (Texte)</label>
<input
value={formData.priceXOF}
onChange={(e) => setFormData({...formData, priceXOF: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Prix EUR (Texte)</label>
<input
value={formData.priceEUR}
onChange={(e) => setFormData({...formData, priceEUR: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Description</label>
<textarea
rows={2}
value={formData.description}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Couleur (Tailwind)</label>
<select
value={formData.color}
onChange={(e) => setFormData({...formData, color: e.target.value})}
className="w-full bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
>
<option value="gray">Gris (Starter)</option>
<option value="brand">Brand (Booster)</option>
<option value="amber">Amber (Empire)</option>
<option value="rose">Rose</option>
<option value="blue">Blue</option>
</select>
</div>
<div className="flex items-end pb-3">
<label className="flex items-center gap-3 cursor-pointer group">
<div className={`w-10 h-6 rounded-full transition-colors relative ${formData.recommended ? 'bg-indigo-600' : 'bg-slate-700'}`}>
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${formData.recommended ? 'translate-x-4' : ''}`} />
</div>
<input
type="checkbox"
className="sr-only"
checked={formData.recommended}
onChange={(e) => setFormData({...formData, recommended: e.target.checked})}
/>
<span className="text-sm font-medium text-slate-300 group-hover:text-white transition-colors uppercase tracking-wider">Mettre en avant</span>
</label>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-400 font-sans uppercase tracking-wider">Avantages du plan</label>
<button
type="button"
onClick={handleAddFeature}
className="text-xs font-bold text-indigo-400 hover:text-indigo-300 flex items-center gap-1 uppercase tracking-tight"
>
<Plus className="w-3 h-3" /> Ajouter un avantage
</button>
</div>
<div className="space-y-3">
{features.map((feature, index) => (
<div key={index} className="flex gap-2">
<input
value={feature}
onChange={(e) => handleFeatureChange(index, e.target.value)}
placeholder="Ex: 50 Offres produits"
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl p-3 text-white focus:outline-none focus:border-indigo-500 transition-colors"
/>
<button
type="button"
onClick={() => handleRemoveFeature(index)}
className="p-3 text-slate-500 hover:text-rose-500 bg-slate-950 border border-slate-700 rounded-xl transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
</form>
</div>
{/* Footer */}
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 rounded-xl font-bold text-slate-400 hover:text-white border border-slate-700 hover:bg-slate-700 transition-all uppercase tracking-wider text-sm"
>
Annuler
</button>
<button
type="submit"
form="plan-form"
disabled={loading}
className="px-8 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-slate-700 text-white rounded-xl font-bold transition-all shadow-lg flex items-center gap-2 uppercase tracking-wider text-sm"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <Save className="w-5 h-5" />}
Sauvegarder
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import React, { useState } from 'react';
import { updateBusinessPlan } from '@/app/actions/plans';
import { toast } from 'react-hot-toast';
import { CreditCard, Loader2 } from 'lucide-react';
interface PlanSelectorProps {
businessId: string;
currentPlan: string;
}
const PLANS = [
{ id: 'STARTER', name: 'Starter', color: 'text-slate-400', bg: 'bg-slate-400/10' },
{ id: 'BOOSTER', name: 'Booster', color: 'text-brand-400', bg: 'bg-brand-400/10' },
{ id: 'EMPIRE', name: 'Empire', color: 'text-amber-400', bg: 'bg-amber-400/10' }
];
export default function PlanSelector({ businessId, currentPlan }: PlanSelectorProps) {
const [isUpdating, setIsUpdating] = useState(false);
const [plan, setPlan] = useState(currentPlan);
const handlePlanChange = async (newPlan: string) => {
setIsUpdating(true);
try {
const result = await updateBusinessPlan(businessId, newPlan as any);
if (result.success) {
setPlan(newPlan);
toast.success(`Forfait mis à jour vers ${newPlan}`);
} else {
toast.error(result.error || "Erreur lors du changement de forfait");
// Reset to old plan in UI
setPlan(currentPlan);
}
} catch (error) {
toast.error("Erreur réseau");
setPlan(currentPlan);
} finally {
setIsUpdating(false);
}
};
return (
<div className="relative inline-flex items-center group">
<div className={`absolute left-2 text-slate-500 pointer-events-none ${isUpdating ? 'animate-spin' : ''}`}>
{isUpdating ? <Loader2 className="w-3.5 h-3.5" /> : <CreditCard className="w-3.5 h-3.5" />}
</div>
<select
value={plan}
onChange={(e) => handlePlanChange(e.target.value)}
disabled={isUpdating}
className={`appearance-none bg-slate-900 border border-slate-700 pl-8 pr-8 py-1.5 rounded-lg text-xs font-bold uppercase tracking-wider focus:outline-none focus:ring-2 focus:ring-brand-500/50 transition-all cursor-pointer hover:border-slate-600 disabled:opacity-50 disabled:cursor-not-allowed ${
PLANS.find(p => p.id === plan)?.color || 'text-white'
}`}
>
{PLANS.map((p) => (
<option key={p.id} value={p.id} className="bg-slate-900 text-white font-sans capitalize">
{p.name}
</option>
))}
</select>
<div className="absolute right-2 pointer-events-none">
<svg className="w-3 h-3 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useState } from 'react';
import { Check, X, Loader2 } from 'lucide-react';
import { updateRatingStatus } from '@/app/actions/moderation';
import { toast } from 'react-hot-toast';
interface RatingModerationButtonsProps {
ratingId: string;
}
export default function RatingModerationButtons({ ratingId }: RatingModerationButtonsProps) {
const [isPending, setIsPending] = useState(false);
const handleAction = async (status: 'APPROVED' | 'REJECTED') => {
setIsPending(true);
try {
const result = await updateRatingStatus(ratingId, status);
if (result.success) {
toast.success(status === 'APPROVED' ? "Avis approuvé !" : "Avis rejeté.");
} else {
toast.error(result.error);
}
} catch (error) {
toast.error("Erreur lors de l'opération");
} finally {
setIsPending(false);
}
};
return (
<div className="flex gap-2">
<button
onClick={() => handleAction('APPROVED')}
disabled={isPending}
className="flex items-center gap-1.5 bg-brand-600 hover:bg-brand-700 text-white px-3 py-1.5 rounded-lg text-xs font-bold transition-all disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
Approuver
</button>
<button
onClick={() => handleAction('REJECTED')}
disabled={isPending}
className="flex items-center gap-1.5 bg-slate-800 hover:bg-red-500/20 hover:text-red-500 text-slate-400 px-3 py-1.5 rounded-lg text-xs font-bold transition-all border border-slate-700 disabled:opacity-50"
>
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <X className="w-3.5 h-3.5" />}
Rejeter
</button>
</div>
);
}

View File

@@ -11,7 +11,10 @@ import {
ShieldCheck,
BarChart3,
Flag,
Settings
CreditCard,
Settings,
Globe,
FileText
} from 'lucide-react';
const menuItems = [
@@ -22,11 +25,22 @@ const menuItems = [
{ name: 'Commentaires', icon: MessageSquare, href: '/comments' },
{ name: 'Modération', icon: Flag, href: '/moderation' },
{ name: 'Blog & Interviews', icon: BookOpen, href: '/blog' },
{ name: 'Tarifs & Plans', icon: CreditCard, href: '/plans' },
{ name: 'Pays', icon: Globe, href: '/countries' },
{ name: 'Documents Légaux', icon: FileText, href: '/legal' },
{ name: 'Configuration', icon: Settings, href: '/settings' },
];
import { useState, useEffect } from 'react';
import { getPendingVerificationCount } from '@/app/actions/business';
export default function Sidebar() {
const pathname = usePathname();
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
getPendingVerificationCount().then(setPendingCount);
}, [pathname]); // Refresh on navigation
return (
<div className="admin-sidebar p-6 flex flex-col">
@@ -44,10 +58,18 @@ export default function Sidebar() {
<Link
key={item.name}
href={item.href}
className={`nav-link ${isActive ? 'active' : ''}`}
className={`nav-link ${isActive ? 'active' : ''} flex items-center justify-between group`}
>
<item.icon className="w-5 h-5 mr-3" />
<span>{item.name}</span>
<div className="flex items-center">
<item.icon className="w-5 h-5 mr-3" />
<span>{item.name}</span>
</div>
{item.name === 'Entrepreneurs' && pendingCount > 0 && (
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shadow-lg group-hover:scale-110 transition-transform">
{pendingCount}
</span>
)}
</Link>
);
})}

View File

@@ -1,6 +1,6 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
// Updated: 2026-04-12 08:34 (Refreshed for new models)
// Updated: 2026-04-18 14:28 (Forced reload for PricingPlan model)
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_v2: PrismaClient }
const globalForPrisma = globalThis as unknown as { prisma_v3: PrismaClient }
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