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