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
Some checks failed
Build and Push App / build (push) Failing after 16m2s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user