425 lines
16 KiB
TypeScript
425 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import {
|
|
visitorsApi,
|
|
OverviewStats,
|
|
ChartData,
|
|
BreakdownData,
|
|
TopPagesData,
|
|
TopReferrersData,
|
|
RealtimeStats,
|
|
CountryStats,
|
|
} from "@/lib/api";
|
|
import {
|
|
Users,
|
|
Globe,
|
|
Monitor,
|
|
Smartphone,
|
|
Tablet,
|
|
TrendingUp,
|
|
TrendingDown,
|
|
RefreshCw,
|
|
BarChart3,
|
|
Activity,
|
|
} from "lucide-react";
|
|
|
|
export default function VisitorStatsPage() {
|
|
const { user, token } = useAuth();
|
|
const [days, setDays] = useState(30);
|
|
const [loading, setLoading] = useState(true);
|
|
const [overview, setOverview] = useState<OverviewStats | null>(null);
|
|
const [visitsChart, setVisitsChart] = useState<ChartData | null>(null);
|
|
const [uniqueChart, setUniqueChart] = useState<ChartData | null>(null);
|
|
const [deviceBreakdown, setDeviceBreakdown] = useState<BreakdownData | null>(null);
|
|
const [browserBreakdown, setBrowserBreakdown] = useState<BreakdownData | null>(null);
|
|
const [osBreakdown, setOsBreakdown] = useState<BreakdownData | null>(null);
|
|
const [topPages, setTopPages] = useState<TopPagesData | null>(null);
|
|
const [topReferrers, setTopReferrers] = useState<TopReferrersData | null>(null);
|
|
const [realtime, setRealtime] = useState<RealtimeStats | null>(null);
|
|
const [countryStats, setCountryStats] = useState<CountryStats[]>([]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
if (!token) return;
|
|
setLoading(true);
|
|
try {
|
|
const [
|
|
overviewRes,
|
|
visitsRes,
|
|
uniqueRes,
|
|
deviceRes,
|
|
browserRes,
|
|
osRes,
|
|
pagesRes,
|
|
referrersRes,
|
|
realtimeRes,
|
|
countryRes,
|
|
] = await Promise.all([
|
|
visitorsApi.adminGetOverview(token, days),
|
|
visitorsApi.adminGetVisitsChart(token, days),
|
|
visitorsApi.adminGetUniqueChart(token, days),
|
|
visitorsApi.adminGetDeviceBreakdown(token, days),
|
|
visitorsApi.adminGetBrowserBreakdown(token, days),
|
|
visitorsApi.adminGetOsBreakdown(token, days),
|
|
visitorsApi.adminGetTopPages(token, days),
|
|
visitorsApi.adminGetTopReferrers(token, days),
|
|
visitorsApi.adminGetRealtime(token),
|
|
visitorsApi.adminGetCountryStats(token),
|
|
]);
|
|
|
|
setOverview(overviewRes);
|
|
setVisitsChart(visitsRes);
|
|
setUniqueChart(uniqueRes);
|
|
setDeviceBreakdown(deviceRes);
|
|
setBrowserBreakdown(browserRes);
|
|
setOsBreakdown(osRes);
|
|
setTopPages(pagesRes);
|
|
setTopReferrers(referrersRes);
|
|
setRealtime(realtimeRes);
|
|
setCountryStats(countryRes);
|
|
} catch (error) {
|
|
console.error("Failed to fetch stats:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token, days]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
// Auto-refresh realtime stats every 30 seconds
|
|
useEffect(() => {
|
|
if (!token) return;
|
|
const interval = setInterval(async () => {
|
|
try {
|
|
const realtimeRes = await visitorsApi.adminGetRealtime(token);
|
|
setRealtime(realtimeRes);
|
|
} catch (error) {
|
|
console.error("Failed to refresh realtime:", error);
|
|
}
|
|
}, 30000);
|
|
return () => clearInterval(interval);
|
|
}, [token]);
|
|
|
|
if (!user?.is_admin) {
|
|
return (
|
|
<div className="p-6 text-center text-red-500">
|
|
Access denied. Admin only.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const getCountryFlag = (countryCode: string) => {
|
|
const flags: Record<string, string> = {
|
|
KR: "🇰🇷", US: "🇺🇸", JP: "🇯🇵", CN: "🇨🇳", DE: "🇩🇪",
|
|
GB: "🇬🇧", FR: "🇫🇷", CA: "🇨🇦", AU: "🇦🇺", IN: "🇮🇳",
|
|
RU: "🇷🇺", BR: "🇧🇷", MX: "🇲🇽", ES: "🇪🇸", IT: "🇮🇹",
|
|
LO: "🏠", "??": "🌍",
|
|
};
|
|
return flags[countryCode] || "🌍";
|
|
};
|
|
|
|
const getDeviceIcon = (deviceType: string) => {
|
|
switch (deviceType.toLowerCase()) {
|
|
case "mobile": return <Smartphone className="w-4 h-4" />;
|
|
case "tablet": return <Tablet className="w-4 h-4" />;
|
|
default: return <Monitor className="w-4 h-4" />;
|
|
}
|
|
};
|
|
|
|
// Simple bar chart component
|
|
const BarChart = ({ data, labels, color = "bg-blue-500" }: { data: number[], labels: string[], color?: string }) => {
|
|
const max = Math.max(...data, 1);
|
|
return (
|
|
<div className="flex items-end gap-1 h-40">
|
|
{data.map((value, index) => (
|
|
<div key={index} className="flex-1 flex flex-col items-center gap-1">
|
|
<div
|
|
className={`w-full ${color} rounded-t transition-all`}
|
|
style={{ height: `${(value / max) * 100}%`, minHeight: value > 0 ? "4px" : "0" }}
|
|
title={`${labels[index]}: ${value}`}
|
|
/>
|
|
{data.length <= 14 && (
|
|
<span className="text-xs text-gray-500 -rotate-45 origin-top-left whitespace-nowrap">
|
|
{labels[index]}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Progress bar component
|
|
const ProgressBar = ({ percentage, color = "bg-blue-500" }: { percentage: number, color?: string }) => (
|
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
<div className={`h-2.5 rounded-full ${color}`} style={{ width: `${percentage}%` }} />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Visitor Statistics</h1>
|
|
<p className="text-gray-500">Detailed analytics for your website</p>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
{/* Period selector */}
|
|
<div className="flex bg-gray-100 rounded-lg p-1">
|
|
{[7, 14, 30, 90].map((d) => (
|
|
<button
|
|
key={d}
|
|
onClick={() => setDays(d)}
|
|
className={`px-3 py-1 rounded-md text-sm font-medium transition ${
|
|
days === d
|
|
? "bg-white text-blue-600 shadow"
|
|
: "text-gray-600 hover:text-gray-900"
|
|
}`}
|
|
>
|
|
{d}d
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* Realtime indicator */}
|
|
<div className="flex items-center gap-2 bg-green-50 text-green-700 px-3 py-1 rounded-full">
|
|
<Activity className="w-4 h-4 animate-pulse" />
|
|
<span className="text-sm font-medium">
|
|
{realtime?.active_visitors || 0} active
|
|
</span>
|
|
</div>
|
|
{/* Refresh button */}
|
|
<button
|
|
onClick={fetchData}
|
|
disabled={loading}
|
|
className="p-2 rounded-lg hover:bg-gray-100 transition"
|
|
>
|
|
<RefreshCw className={`w-5 h-5 text-gray-600 ${loading ? "animate-spin" : ""}`} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Overview Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">Total Visits</p>
|
|
<p className="text-3xl font-bold text-gray-900">
|
|
{overview?.total_visits?.toLocaleString() || 0}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-blue-100 rounded-full">
|
|
<BarChart3 className="w-6 h-6 text-blue-600" />
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-2">Last {days} days</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">Unique Visitors</p>
|
|
<p className="text-3xl font-bold text-gray-900">
|
|
{overview?.unique_visitors?.toLocaleString() || 0}
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-green-100 rounded-full">
|
|
<Users className="w-6 h-6 text-green-600" />
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-2">
|
|
{overview?.pages_per_visit || 0} pages/visit
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">Today</p>
|
|
<p className="text-3xl font-bold text-gray-900">
|
|
{overview?.today_visitors || 0}
|
|
</p>
|
|
</div>
|
|
<div className={`p-3 rounded-full ${
|
|
(overview?.growth_rate || 0) >= 0 ? "bg-green-100" : "bg-red-100"
|
|
}`}>
|
|
{(overview?.growth_rate || 0) >= 0 ? (
|
|
<TrendingUp className="w-6 h-6 text-green-600" />
|
|
) : (
|
|
<TrendingDown className="w-6 h-6 text-red-600" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
<p className={`text-sm mt-2 ${
|
|
(overview?.growth_rate || 0) >= 0 ? "text-green-600" : "text-red-600"
|
|
}`}>
|
|
{(overview?.growth_rate || 0) >= 0 ? "+" : ""}{overview?.growth_rate || 0}% vs yesterday
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-500">Mobile</p>
|
|
<p className="text-3xl font-bold text-gray-900">
|
|
{overview?.mobile_percentage || 0}%
|
|
</p>
|
|
</div>
|
|
<div className="p-3 bg-purple-100 rounded-full">
|
|
<Smartphone className="w-6 h-6 text-purple-600" />
|
|
</div>
|
|
</div>
|
|
<p className="text-sm text-gray-500 mt-2">of all visitors</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Charts Row */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Daily Visits Chart */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Daily Visits</h3>
|
|
{visitsChart && (
|
|
<BarChart data={visitsChart.data} labels={visitsChart.labels} color="bg-blue-500" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Unique Visitors Chart */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Unique Visitors</h3>
|
|
{uniqueChart && (
|
|
<BarChart data={uniqueChart.data} labels={uniqueChart.labels} color="bg-green-500" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Country Stats */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
<Globe className="w-5 h-5" />
|
|
Visitors by Country
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{countryStats.map((stat, index) => {
|
|
const total = countryStats.reduce((sum, s) => sum + s.visitor_count, 0);
|
|
const percentage = total > 0 ? (stat.visitor_count / total) * 100 : 0;
|
|
return (
|
|
<div key={index} className="flex items-center gap-3">
|
|
<span className="text-xl">{getCountryFlag(stat.country_code)}</span>
|
|
<span className="w-32 font-medium text-gray-700">{stat.country}</span>
|
|
<div className="flex-1">
|
|
<ProgressBar percentage={percentage} color="bg-blue-500" />
|
|
</div>
|
|
<span className="w-16 text-right text-gray-600">
|
|
{stat.visitor_count.toLocaleString()}
|
|
</span>
|
|
<span className="w-16 text-right text-gray-400">
|
|
({percentage.toFixed(1)}%)
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{countryStats.length === 0 && (
|
|
<p className="text-gray-500 text-center py-4">No country data available</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breakdowns Row */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
{/* Device Breakdown */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Device Type</h3>
|
|
<div className="space-y-3">
|
|
{deviceBreakdown?.items.map((item, index) => (
|
|
<div key={index} className="flex items-center gap-3">
|
|
{getDeviceIcon(item.name)}
|
|
<span className="flex-1 text-gray-700 capitalize">{item.name}</span>
|
|
<span className="text-gray-600">{item.count}</span>
|
|
<span className="text-gray-400 w-12 text-right">({item.percentage}%)</span>
|
|
</div>
|
|
))}
|
|
{(!deviceBreakdown?.items || deviceBreakdown.items.length === 0) && (
|
|
<p className="text-gray-500 text-center py-2">No data</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Browser Breakdown */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Browser</h3>
|
|
<div className="space-y-3">
|
|
{browserBreakdown?.items.map((item, index) => (
|
|
<div key={index} className="flex items-center gap-3">
|
|
<span className="flex-1 text-gray-700">{item.name}</span>
|
|
<span className="text-gray-600">{item.count}</span>
|
|
<span className="text-gray-400 w-12 text-right">({item.percentage}%)</span>
|
|
</div>
|
|
))}
|
|
{(!browserBreakdown?.items || browserBreakdown.items.length === 0) && (
|
|
<p className="text-gray-500 text-center py-2">No data</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* OS Breakdown */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Operating System</h3>
|
|
<div className="space-y-3">
|
|
{osBreakdown?.items.map((item, index) => (
|
|
<div key={index} className="flex items-center gap-3">
|
|
<span className="flex-1 text-gray-700">{item.name}</span>
|
|
<span className="text-gray-600">{item.count}</span>
|
|
<span className="text-gray-400 w-12 text-right">({item.percentage}%)</span>
|
|
</div>
|
|
))}
|
|
{(!osBreakdown?.items || osBreakdown.items.length === 0) && (
|
|
<p className="text-gray-500 text-center py-2">No data</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Pages & Referrers */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Top Pages */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Pages</h3>
|
|
<div className="space-y-3">
|
|
{topPages?.pages.map((page, index) => (
|
|
<div key={index} className="flex items-center gap-3">
|
|
<span className="w-6 text-gray-400">{index + 1}.</span>
|
|
<span className="flex-1 text-gray-700 truncate font-mono text-sm">{page.path}</span>
|
|
<span className="text-gray-600">{page.count}</span>
|
|
</div>
|
|
))}
|
|
{(!topPages?.pages || topPages.pages.length === 0) && (
|
|
<p className="text-gray-500 text-center py-4">No page data available</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top Referrers */}
|
|
<div className="bg-white rounded-xl shadow p-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Top Referrers</h3>
|
|
<div className="space-y-3">
|
|
{topReferrers?.referrers.map((ref, index) => (
|
|
<div key={index} className="flex items-center gap-3">
|
|
<span className="w-6 text-gray-400">{index + 1}.</span>
|
|
<span className="flex-1 text-gray-700 truncate">{ref.domain}</span>
|
|
<span className="text-gray-600">{ref.count}</span>
|
|
</div>
|
|
))}
|
|
{(!topReferrers?.referrers || topReferrers.referrers.length === 0) && (
|
|
<p className="text-gray-500 text-center py-4">No referrer data available</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|