Files
QR-master/src/app/(app)/analytics/page.tsx

594 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import dynamic from 'next/dynamic';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Table } from '@/components/ui/Table';
import { useTranslation } from '@/hooks/useTranslation';
import { StatCard, Sparkline } from '@/components/analytics';
import { Line, Doughnut } from 'react-chartjs-2';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import {
BarChart3,
Users,
Smartphone,
Globe,
Calendar,
Download,
TrendingUp,
QrCode,
HelpCircle,
} from 'lucide-react';
// Dynamically import GeoMap to avoid SSR issues with d3
const GeoMap = dynamic(() => import('@/components/analytics/GeoMap'), {
ssr: false,
loading: () => (
<div className="h-64 bg-gray-100 rounded-lg animate-pulse flex items-center justify-center">
<span className="text-gray-400">Loading map...</span>
</div>
),
});
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Title,
Tooltip,
Legend,
Filler
);
interface QRPerformance {
id: string;
title: string;
type: string;
totalScans: number;
uniqueScans: number;
conversion: number;
trend: 'up' | 'down' | 'flat';
trendPercentage: number;
sparkline: number[];
lastScanned: string | null;
isNew?: boolean;
}
interface CountryStat {
country: string;
count: number;
percentage: number;
trend: 'up' | 'down' | 'flat';
trendPercentage: number;
isNew?: boolean;
}
interface AnalyticsData {
summary: {
totalScans: number;
uniqueScans: number;
avgScansPerQR: number;
mobilePercentage: number;
topCountry: string;
topCountryPercentage: number;
scansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
avgScansTrend?: { trend: 'up' | 'down' | 'flat'; percentage: number; isNew?: boolean };
comparisonPeriod?: string;
};
deviceStats: Record<string, number>;
countryStats: CountryStat[];
dailyScans: Record<string, number>;
qrPerformance: QRPerformance[];
}
export default function AnalyticsPage() {
const { t } = useTranslation();
const [timeRange, setTimeRange] = useState('7');
const [loading, setLoading] = useState(true);
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(null);
const fetchAnalytics = useCallback(async () => {
setLoading(true);
try {
const response = await fetch(`/api/analytics/summary?range=${timeRange}`);
if (response.ok) {
const data = await response.json();
setAnalyticsData(data);
} else {
setAnalyticsData(null);
}
} catch (error) {
console.error('Error fetching analytics:', error);
setAnalyticsData(null);
} finally {
setLoading(false);
}
}, [timeRange]);
useEffect(() => {
fetchAnalytics();
}, [fetchAnalytics]);
const exportReport = () => {
if (!analyticsData) return;
const csvData = [
['QR Master Analytics Report'],
['Generated:', new Date().toLocaleString()],
['Time Range:', `Last ${timeRange} days`],
[''],
['Summary'],
['Total Scans', analyticsData.summary.totalScans],
['Unique Scans', analyticsData.summary.uniqueScans],
['Mobile Usage %', analyticsData.summary.mobilePercentage],
['Top Country', analyticsData.summary.topCountry],
[''],
['Top QR Codes'],
['Title', 'Type', 'Total Scans', 'Unique Scans', 'Conversion %', 'Last Scanned'],
...analyticsData.qrPerformance.map((qr) => [
qr.title,
qr.type,
qr.totalScans,
qr.uniqueScans,
qr.conversion,
qr.lastScanned ? new Date(qr.lastScanned).toLocaleString() : 'Never',
]),
];
const csv = csvData.map((row) => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `qr-analytics-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// Prepare chart data
const daysToShow = parseInt(timeRange);
const dateRange = Array.from({ length: daysToShow }, (_, i) => {
const date = new Date();
date.setDate(date.getDate() - (daysToShow - 1 - i));
return date.toISOString().split('T')[0];
});
const scanChartData = {
labels: dateRange.map((date) => {
const d = new Date(date);
return d.toLocaleDateString('en', { month: 'short', day: 'numeric' });
}),
datasets: [
{
label: 'Scans',
data: dateRange.map((date) => analyticsData?.dailyScans[date] || 0),
borderColor: 'rgb(59, 130, 246)',
backgroundColor: (context: any) => {
const chart = context.chart;
const { ctx, chartArea } = chart;
if (!chartArea) return 'rgba(59, 130, 246, 0.1)';
const gradient = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
gradient.addColorStop(0, 'rgba(59, 130, 246, 0.3)');
gradient.addColorStop(1, 'rgba(59, 130, 246, 0.01)');
return gradient;
},
tension: 0.4,
fill: true,
pointRadius: 4,
pointBackgroundColor: 'rgb(59, 130, 246)',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointHoverRadius: 6,
},
],
};
const deviceChartData = {
labels: ['Desktop', 'Mobile', 'Tablet'],
datasets: [
{
data: [
analyticsData?.deviceStats.desktop || 0,
analyticsData?.deviceStats.mobile || 0,
analyticsData?.deviceStats.tablet || 0,
],
backgroundColor: [
'rgba(59, 130, 246, 0.85)',
'rgba(34, 197, 94, 0.85)',
'rgba(249, 115, 22, 0.85)',
],
borderWidth: 0,
hoverOffset: 4,
},
],
};
// Find top performing QR code
const topQR = analyticsData?.qrPerformance[0];
if (loading) {
return (
<div className="space-y-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2 mb-8" />
<div className="grid md:grid-cols-4 gap-6 mb-8">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-32 bg-gray-200 rounded-xl" />
))}
</div>
<div className="grid lg:grid-cols-2 gap-6">
<div className="h-80 bg-gray-200 rounded-xl" />
<div className="h-80 bg-gray-200 rounded-xl" />
</div>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">QR Code Analytics</h1>
<p className="text-gray-500 mt-1">Track and analyze your QR code performance</p>
</div>
<div className="flex items-center gap-3">
{/* Date Range Selector */}
<div className="inline-flex items-center bg-gray-100 rounded-lg p-1">
{[
{ value: '7', label: '7 Days' },
{ value: '30', label: '30 Days' },
{ value: '90', label: '90 Days' },
].map((range) => (
<button
key={range.value}
onClick={() => setTimeRange(range.value)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-all ${timeRange === range.value
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{range.label}
</button>
))}
</div>
<Button onClick={exportReport} variant="primary" className="flex items-center gap-2">
<Download className="w-4 h-4" />
Export Report
</Button>
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard
title="Total Scans"
value={analyticsData?.summary.totalScans || 0}
trend={
analyticsData?.summary.scansTrend
? {
direction: analyticsData.summary.scansTrend.trend,
percentage: analyticsData.summary.scansTrend.percentage,
isNew: analyticsData.summary.scansTrend.isNew,
period: analyticsData.summary.comparisonPeriod,
}
: undefined
}
icon={<BarChart3 className="w-5 h-5 text-primary-600" />}
/>
<StatCard
title="Avg Scans/QR"
value={analyticsData?.summary.avgScansPerQR || 0}
trend={
analyticsData?.summary.avgScansTrend
? {
direction: analyticsData.summary.avgScansTrend.trend,
percentage: analyticsData.summary.avgScansTrend.percentage,
isNew: analyticsData.summary.avgScansTrend.isNew,
period: analyticsData.summary.comparisonPeriod,
}
: undefined
}
icon={<TrendingUp className="w-5 h-5 text-primary-600" />}
/>
<StatCard
title="Mobile Usage"
value={`${analyticsData?.summary.mobilePercentage || 0}%`}
subtitle="Of total scans"
icon={<Smartphone className="w-5 h-5 text-primary-600" />}
/>
<StatCard
title="Top Country"
value={analyticsData?.summary.topCountry || 'N/A'}
subtitle={`${analyticsData?.summary.topCountryPercentage || 0}% of total`}
icon={<Globe className="w-5 h-5 text-primary-600" />}
/>
</div>
{/* Main Chart Row */}
<div className="grid lg:grid-cols-3 gap-6">
{/* Scans Over Time - Takes 2 columns */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-semibold">Scan Trends Over Time</CardTitle>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Calendar className="w-4 h-4" />
<span>{timeRange} Days</span>
</div>
</CardHeader>
<CardContent>
<div className="h-72">
<Line
data={scanChartData}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(17, 24, 39, 0.9)',
titleColor: '#fff',
bodyColor: '#fff',
padding: 12,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
displayColors: false,
callbacks: {
title: (items) => items[0]?.label || '',
label: (item) => `${item.formattedValue} scans`,
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#9CA3AF' },
},
y: {
beginAtZero: true,
grid: { color: 'rgba(156, 163, 175, 0.1)' },
ticks: { color: '#9CA3AF', precision: 0 },
},
},
}}
/>
</div>
</CardContent>
</Card>
{/* Device Types Donut */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Device Types</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64 flex items-center justify-center">
{(analyticsData?.summary.totalScans || 0) > 0 ? (
<Doughnut
data={deviceChartData}
options={{
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
},
},
},
}}
/>
) : (
<p className="text-gray-400">No scan data available</p>
)}
</div>
</CardContent>
</Card>
</div>
{/* Geographic & Country Stats Row */}
<div className="grid lg:grid-cols-2 gap-6">
{/* Geographic Insights with Map */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Geographic Insights</CardTitle>
</CardHeader>
<CardContent>
<div className="h-64">
<GeoMap
countryStats={analyticsData?.countryStats || []}
totalScans={analyticsData?.summary.totalScans || 0}
/>
</div>
</CardContent>
</Card>
{/* Top Countries Table */}
<Card>
<CardHeader>
<CardTitle className="text-lg font-semibold">Top Countries</CardTitle>
</CardHeader>
<CardContent>
{(analyticsData?.countryStats?.length || 0) > 0 ? (
<div className="space-y-3">
{analyticsData!.countryStats.slice(0, 5).map((country, index) => (
<div
key={country.country}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<span className="text-lg font-semibold text-gray-400 w-6">
{index + 1}
</span>
<span className="font-medium text-gray-900">{country.country}</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-600">{country.count.toLocaleString()}</span>
<span className="text-gray-400 text-sm w-12 text-right">
{country.percentage}%
</span>
<Badge
variant={
country.trend === 'up'
? 'success'
: country.trend === 'down'
? 'error'
: 'default'
}
>
{country.trend === 'up' ? '↑' : country.trend === 'down' ? '↓' : '→'}
{country.trendPercentage}%{country.isNew ? ' (new)' : ''}
</Badge>
</div>
</div>
))}
</div>
) : (
<p className="text-gray-400 text-center py-8">No country data available yet</p>
)}
</CardContent>
</Card>
</div>
{/* Top Performing QR Codes with Sparklines */}
<Card className="overflow-visible">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-semibold flex items-center gap-2">
<QrCode className="w-5 h-5" />
Top Performing QR Codes
</CardTitle>
</CardHeader>
<CardContent>
{(analyticsData?.qrPerformance?.length || 0) > 0 ? (
<div className="overflow-x-auto overflow-y-visible">
<Table>
<thead>
<tr className="border-b border-gray-100">
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
QR Code
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Type
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Total Scans
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Unique Scans
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
<div className="flex items-center gap-1.5">
<span>Conversions</span>
<div className="group relative inline-block">
<HelpCircle className="w-3.5 h-3.5 text-gray-400 cursor-help" />
<div className="invisible group-hover:visible absolute top-full left-1/2 -translate-x-1/2 mt-2 w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-xl z-[9999] pointer-events-none">
<div className="font-semibold mb-1">Conversion Rate</div>
<div className="text-gray-300">
Percentage of unique scans vs total scans. Formula: (Unique Scans / Total Scans) × 100%
</div>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-b-4 border-l-transparent border-r-transparent border-b-gray-900"></div>
</div>
</div>
</div>
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-gray-500">
Trend
</th>
</tr>
</thead>
<tbody>
{analyticsData!.qrPerformance.map((qr) => (
<tr
key={qr.id}
className="border-b border-gray-50 transition-colors hover:bg-gray-50/50"
>
<td className="px-4 py-4 align-middle">
<span className="font-medium text-gray-900">{qr.title}</span>
</td>
<td className="px-4 py-4 align-middle">
<Badge variant={qr.type === 'DYNAMIC' ? 'info' : 'default'}>
{qr.type}
</Badge>
</td>
<td className="px-4 py-4 align-middle font-medium">
{qr.totalScans.toLocaleString()}
</td>
<td className="px-4 py-4 align-middle">{qr.uniqueScans.toLocaleString()}</td>
<td className="px-4 py-4 align-middle">{qr.conversion}%</td>
<td className="px-4 py-4 align-middle">
<div className="flex items-center gap-3">
<Sparkline
data={qr.sparkline || [0, 0, 0, 0, 0, 0, 0]}
color={
qr.trend === 'up'
? 'green'
: qr.trend === 'down'
? 'red'
: 'blue'
}
/>
<Badge
variant={
qr.trend === 'up'
? 'success'
: qr.trend === 'down'
? 'error'
: 'default'
}
>
{qr.trend === 'up' ? '↑' : qr.trend === 'down' ? '↓' : '→'}
{qr.trendPercentage}%{qr.isNew ? ' (new)' : ''}
</Badge>
</div>
</td>
</tr>
))}
</tbody>
</Table>
</div>
) : (
<div className="text-center py-12">
<QrCode className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">
No QR codes created yet. Create your first QR code to see analytics!
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}