664 lines
26 KiB
TypeScript
664 lines
26 KiB
TypeScript
import { prisma } from '@/lib/prisma';
|
|
import Link from 'next/link';
|
|
import {
|
|
BarChart3,
|
|
MousePointer2,
|
|
Clock,
|
|
Eye,
|
|
ArrowUpRight,
|
|
TrendingUp,
|
|
Globe,
|
|
Users,
|
|
Zap,
|
|
Search,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
Calendar,
|
|
Map as MapIcon,
|
|
BookOpen
|
|
} from 'lucide-react';
|
|
import AnalyticsMap from '@/components/AnalyticsMap';
|
|
import CountryHistogram from '@/components/CountryHistogram';
|
|
|
|
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;
|
|
|
|
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>
|
|
);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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: { type: 'PAGE_VIEW', ...currentWhere },
|
|
_count: { id: true },
|
|
orderBy: { _count: { id: 'desc' } },
|
|
take: 50 // Increased to get more map data
|
|
});
|
|
|
|
// Unique visitors per country
|
|
const uniqueVisitorsPerCountryRaw = await prisma.analyticsEvent.groupBy({
|
|
by: ['country', 'ip'],
|
|
where: { type: 'PAGE_VIEW', ...currentWhere }
|
|
});
|
|
|
|
const uniqueVisitorsPerCountryMap: Record<string, number> = {};
|
|
uniqueVisitorsPerCountryRaw.forEach(item => {
|
|
const country = item.country || 'Unknown';
|
|
uniqueVisitorsPerCountryMap[country] = (uniqueVisitorsPerCountryMap[country] || 0) + 1;
|
|
});
|
|
|
|
const uniqueVisitorsPerCountry = Object.entries(uniqueVisitorsPerCountryMap)
|
|
.map(([country, count]) => ({ country, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
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;
|
|
|
|
// Article page views (impressions)
|
|
const pageViewsByPath = await prisma.analyticsEvent.groupBy({
|
|
by: ['path'],
|
|
where: {
|
|
type: 'PAGE_VIEW',
|
|
path: { startsWith: '/actualites/' },
|
|
...currentWhere
|
|
},
|
|
_count: { id: true }
|
|
});
|
|
|
|
// Article dwell times (reading times)
|
|
const dwellTimeByPath = await prisma.analyticsEvent.groupBy({
|
|
by: ['path'],
|
|
where: {
|
|
type: 'DWELL_TIME',
|
|
path: { startsWith: '/actualites/' },
|
|
...currentWhere
|
|
},
|
|
_avg: { value: true },
|
|
_count: { id: true }
|
|
});
|
|
|
|
// Article unique visitors grouping
|
|
const uniqueVisitorsRaw = await prisma.analyticsEvent.groupBy({
|
|
by: ['path', 'ip'],
|
|
where: {
|
|
type: 'PAGE_VIEW',
|
|
path: { startsWith: '/actualites/' },
|
|
...currentWhere
|
|
}
|
|
});
|
|
|
|
// Load blog posts to associate with analytics
|
|
const blogPosts = await prisma.blogPost.findMany({
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
slug: true,
|
|
author: true,
|
|
publishedAt: true,
|
|
}
|
|
});
|
|
|
|
const totalArticleViews = await prisma.analyticsEvent.count({
|
|
where: {
|
|
type: 'PAGE_VIEW',
|
|
path: { startsWith: '/actualites/' },
|
|
...currentWhere
|
|
}
|
|
});
|
|
|
|
// 4. Fetch Previous Data (for Deltas)
|
|
let prevTotalEvents = 0;
|
|
let prevUniqueVisitors = 0;
|
|
let prevMobileCount = 0;
|
|
let prevArticleViews = 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;
|
|
|
|
prevArticleViews = await prisma.analyticsEvent.count({
|
|
where: {
|
|
type: 'PAGE_VIEW',
|
|
path: { startsWith: '/actualites/' },
|
|
...prevWhere
|
|
}
|
|
});
|
|
}
|
|
|
|
// 5. Recent Unique IPs
|
|
const recentUniqueIPs = await prisma.analyticsEvent.groupBy({
|
|
by: ['ip'],
|
|
where: currentWhere,
|
|
_max: { createdAt: true },
|
|
orderBy: { _max: { createdAt: 'desc' } },
|
|
take: 20
|
|
});
|
|
|
|
const recentIPs = await Promise.all(
|
|
recentUniqueIPs.map(async (item) => {
|
|
if (!item.ip || !item._max.createdAt) return null;
|
|
return await prisma.analyticsEvent.findFirst({
|
|
where: {
|
|
ip: item.ip,
|
|
createdAt: item._max.createdAt
|
|
}
|
|
});
|
|
})
|
|
);
|
|
|
|
const currentMobileCount = devices.find(d => d.device === 'Mobile')?._count.id || 0;
|
|
|
|
return {
|
|
topPages,
|
|
topCountries,
|
|
devices,
|
|
browsers,
|
|
totalEvents,
|
|
uniqueVisitors,
|
|
recentIPs,
|
|
uniqueVisitorsPerCountry,
|
|
totalArticleViews,
|
|
articleStats: blogPosts.map(post => {
|
|
const paths = [`/actualites/${post.id}`];
|
|
if (post.slug) {
|
|
paths.push(`/actualites/${post.slug}`);
|
|
}
|
|
|
|
let views = 0;
|
|
pageViewsByPath.forEach(item => {
|
|
if (paths.includes(item.path)) {
|
|
views += item._count.id;
|
|
}
|
|
});
|
|
|
|
const uniqueIps = new Set<string>();
|
|
uniqueVisitorsRaw.forEach(item => {
|
|
if (paths.includes(item.path) && item.ip) {
|
|
uniqueIps.add(item.ip);
|
|
}
|
|
});
|
|
const visitors = uniqueIps.size;
|
|
|
|
let totalDwellTime = 0;
|
|
let dwellCount = 0;
|
|
dwellTimeByPath.forEach(item => {
|
|
if (paths.includes(item.path) && item._avg.value && item._count.id) {
|
|
totalDwellTime += item._avg.value * item._count.id;
|
|
dwellCount += item._count.id;
|
|
}
|
|
});
|
|
const avgDwellTime = dwellCount > 0 ? Math.round(totalDwellTime / dwellCount) : 0;
|
|
|
|
return {
|
|
id: post.id,
|
|
title: post.title,
|
|
author: post.author,
|
|
publishedAt: post.publishedAt,
|
|
views,
|
|
visitors,
|
|
avgDwellTime
|
|
};
|
|
}).sort((a, b) => b.views - a.views),
|
|
deltas: {
|
|
events: prevTotalEvents > 0 ? ((totalEvents - prevTotalEvents) / prevTotalEvents) * 100 : null,
|
|
visitors: prevUniqueVisitors > 0 ? ((uniqueVisitors - prevUniqueVisitors) / prevUniqueVisitors) * 100 : null,
|
|
articleViews: prevArticleViews > 0 ? ((totalArticleViews - prevArticleViews) / prevArticleViews) * 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, recentIPs, deltas, uniqueVisitorsPerCountry, totalArticleViews, articleStats } = 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 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-4 gap-6">
|
|
<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.toLocaleString()}</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">
|
|
Personnes réelles distinctes
|
|
</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>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-white">{totalEvents.toLocaleString()}</div>
|
|
<ComparisonBadge value={deltas.events} />
|
|
</div>
|
|
</div>
|
|
<h3 className="text-slate-400 text-sm font-medium">Impressions (Pages vues)</h3>
|
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
|
Nombre total de fois où les pages ont été vues
|
|
</p>
|
|
</div>
|
|
|
|
<div className="card border-t-4 border-rose-500">
|
|
<div className="flex justify-between items-start mb-4">
|
|
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-400">
|
|
<BookOpen className="w-6 h-6" />
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="text-2xl font-bold text-white">{totalArticleViews.toLocaleString()}</div>
|
|
<ComparisonBadge value={deltas.articleViews} />
|
|
</div>
|
|
</div>
|
|
<h3 className="text-slate-400 text-sm font-medium">Articles consultés</h3>
|
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
|
Vues totales des articles de blog
|
|
</p>
|
|
</div>
|
|
|
|
<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">
|
|
<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>
|
|
</div>
|
|
<h3 className="text-slate-400 text-sm font-medium">Trafic Mobile</h3>
|
|
<p className="text-[10px] text-slate-500 uppercase mt-1 font-bold tracking-wider">
|
|
Part des appareils mobiles
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* World Map Section */}
|
|
<AnalyticsMap
|
|
data={uniqueVisitorsPerCountry}
|
|
title="Répartition Géographique (Visiteurs Uniques)"
|
|
/>
|
|
|
|
{/* Histogram Section */}
|
|
<CountryHistogram
|
|
title="Comparatif Impressions vs Visiteurs Uniques"
|
|
data={topCountries.map(c => ({
|
|
country: c.country || 'Inconnu',
|
|
impressions: c._count.id,
|
|
visitors: uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0
|
|
}))}
|
|
/>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* 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">
|
|
<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">Impressions</th>
|
|
<th className="px-6 py-4 text-right">V. Uniques</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800">
|
|
{topCountries.map((c) => {
|
|
const uniqueCount = uniqueVisitorsPerCountry.find(uv => uv.country === c.country)?.count || 0;
|
|
return (
|
|
<tr key={c.country} className="hover:bg-slate-800/30 transition-colors text-xs">
|
|
<td className="px-6 py-4 text-slate-300 font-bold flex items-center gap-2">
|
|
<span className="w-2 h-2 rounded-full bg-indigo-500"></span>
|
|
{c.country || 'Inconnu'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right font-medium text-slate-400">
|
|
{c._count.id.toLocaleString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
|
{uniqueCount.toLocaleString()}
|
|
</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 (Restored) */}
|
|
<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">
|
|
<thead className="bg-slate-900/50 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
|
<tr>
|
|
<th className="px-6 py-4">URL / Chemin</th>
|
|
<th className="px-6 py-4 text-right">Vues</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800">
|
|
{topPages.map((page) => (
|
|
<tr key={page.path} className="hover:bg-slate-800/30 transition-colors">
|
|
<td className="px-6 py-4 text-sm font-mono text-slate-300">{page.path}</td>
|
|
<td className="px-6 py-4 text-right text-sm font-bold text-white">
|
|
{page._count.id}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Article Statistics 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">
|
|
<BookOpen className="w-5 h-5 text-rose-400" /> Statistiques par article
|
|
</h2>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<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">Article</th>
|
|
<th className="px-6 py-4">Auteur</th>
|
|
<th className="px-6 py-4 text-right">Vues</th>
|
|
<th className="px-6 py-4 text-right">V. Uniques</th>
|
|
<th className="px-6 py-4 text-right">Temps de lecture moyen</th>
|
|
<th className="px-6 py-4 text-right">Date de publication</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800">
|
|
{articleStats.map((post) => (
|
|
<tr key={post.id} className="hover:bg-slate-800/30 transition-colors text-xs">
|
|
<td className="px-6 py-4 font-semibold text-slate-200">
|
|
{post.title}
|
|
</td>
|
|
<td className="px-6 py-4 text-slate-400">
|
|
{post.author}
|
|
</td>
|
|
<td className="px-6 py-4 text-right font-bold text-white">
|
|
{post.views.toLocaleString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-right font-bold text-indigo-400">
|
|
{post.visitors.toLocaleString()}
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-slate-300 font-mono">
|
|
{post.avgDwellTime > 0 ? `${post.avgDwellTime}s` : '---'}
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-slate-400">
|
|
{post.publishedAt ? new Date(post.publishedAt).toLocaleDateString('fr-FR') : 'Non publié'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Recent IP Connections 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">
|
|
<Users className="w-5 h-5 text-indigo-400" /> Dernières Connexions (IP)
|
|
</h2>
|
|
</div>
|
|
<div className="overflow-x-auto">
|
|
<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">Adresse IP</th>
|
|
<th className="px-6 py-4">Localisation</th>
|
|
<th className="px-6 py-4 text-center">Appareil / OS</th>
|
|
<th className="px-6 py-4">Navigateur</th>
|
|
<th className="px-6 py-4">Dernière Page</th>
|
|
<th className="px-6 py-4 text-right">Date / Heure</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800">
|
|
{recentIPs.map((e, idx) => e && (
|
|
<tr key={`${e.ip}-${idx}`} className="hover:bg-slate-800/30 transition-colors">
|
|
<td className="px-6 py-4">
|
|
<span className="font-mono text-xs text-indigo-400 bg-indigo-400/5 px-2 py-1 rounded border border-indigo-400/10 whitespace-nowrap">
|
|
{e.ip || '---.---.---.---'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm text-slate-200 font-bold">{e.country || 'Inconnu'}</span>
|
|
<span className="text-[10px] text-slate-500">{e.city || '---'}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-center">
|
|
<div className="inline-flex flex-col items-center">
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-bold mb-1 ${
|
|
e.device === 'Mobile' ? 'bg-amber-500/10 text-amber-500' : 'bg-blue-500/10 text-blue-500'
|
|
}`}>
|
|
{e.device}
|
|
</span>
|
|
<span className="text-[10px] text-slate-400">{e.os}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 text-sm text-slate-300">
|
|
{e.browser}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className="text-xs font-mono text-slate-400 truncate max-w-[150px] block" title={e.path}>
|
|
{e.path}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-right text-xs whitespace-nowrap">
|
|
<div className="flex flex-col">
|
|
<span className="text-slate-300">{new Date(e.createdAt).toLocaleDateString('fr-FR')}</span>
|
|
<span className="text-slate-500">{new Date(e.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</span>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|