Files
portfolio/components/AnalyticsDashboard.tsx
denshooter 690d9e1cfb Fix remaining merge conflicts and linter errors
- Remove merge conflict markers from AnalyticsDashboard.tsx
- Fix merge conflicts in email/respond/route.tsx
- Use dev versions of EmailManager and ModernAdminDashboard
- Add eslint-disable for Image icon in editor
2025-09-10 11:13:27 +02:00

585 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
BarChart3,
TrendingUp,
Eye,
Heart,
Zap,
Globe,
Activity,
Target,
Award,
RefreshCw,
MousePointer,
RotateCcw,
Trash2,
AlertTriangle
} from 'lucide-react';
interface AnalyticsData {
overview: {
totalProjects: number;
publishedProjects: number;
featuredProjects: number;
totalViews: number;
totalLikes: number;
totalShares: number;
avgLighthouse: number;
};
projects: Array<{
id: number;
title: string;
category: string;
difficulty: string;
views: number;
likes: number;
shares: number;
lighthouse: number;
published: boolean;
featured: boolean;
createdAt: string;
updatedAt: string;
}>;
categories: Record<string, number>;
difficulties: Record<string, number>;
performance: {
avgLighthouse: number;
totalViews: number;
totalLikes: number;
totalShares: number;
};
metrics: {
bounceRate: number;
avgSessionDuration: number;
pagesPerSession: number;
newUsers: number;
};
}
interface AnalyticsDashboardProps {
isAuthenticated: boolean;
}
export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps) {
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | '1y'>('30d');
const [showResetModal, setShowResetModal] = useState(false);
const [resetType, setResetType] = useState<'analytics' | 'pageviews' | 'interactions' | 'performance' | 'all'>('analytics');
const [resetting, setResetting] = useState(false);
const fetchAnalyticsData = useCallback(async () => {
if (!isAuthenticated) return;
try {
setLoading(true);
setError(null);
const [analyticsRes, performanceRes] = await Promise.all([
fetch('/api/analytics/dashboard', {
headers: { 'x-admin-request': 'true' }
}),
fetch('/api/analytics/performance', {
headers: { 'x-admin-request': 'true' }
})
]);
if (!analyticsRes.ok || !performanceRes.ok) {
throw new Error('Failed to fetch analytics data');
}
const [analytics, performance] = await Promise.all([
analyticsRes.json(),
performanceRes.json()
]);
setData({
overview: analytics.overview || {
totalProjects: 0,
publishedProjects: 0,
featuredProjects: 0,
totalViews: 0,
totalLikes: 0,
totalShares: 0,
avgLighthouse: 90
},
projects: analytics.projects || [],
categories: analytics.categories || {},
difficulties: analytics.difficulties || {},
performance: performance.performance || {
avgLighthouse: 90,
totalViews: 0,
totalLikes: 0,
totalShares: 0
},
metrics: performance.metrics || {
bounceRate: 35,
avgSessionDuration: 180,
pagesPerSession: 2.5,
newUsers: 0
}
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load analytics');
} finally {
setLoading(false);
}
}, [isAuthenticated]);
const resetAnalytics = async () => {
if (!isAuthenticated || resetting) return;
setResetting(true);
try {
const response = await fetch('/api/analytics/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-request': 'true'
},
body: JSON.stringify({ type: resetType })
});
if (response.ok) {
await fetchAnalyticsData(); // Refresh data
setShowResetModal(false);
} else {
const errorData = await response.json();
setError(errorData.error || 'Failed to reset analytics');
}
} catch (err) {
setError('Failed to reset analytics');
console.error('Reset error:', err);
} finally {
setResetting(false);
}
};
useEffect(() => {
if (isAuthenticated) {
fetchAnalyticsData();
}
}, [isAuthenticated, fetchAnalyticsData]);
const StatCard = ({ title, value, icon: Icon, color, trend, trendValue, description }: {
title: string;
value: number | string;
icon: React.ComponentType<{ className?: string; size?: number }>;
color: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
description?: string;
}) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="admin-glass-card p-6 rounded-xl hover:scale-105 transition-all duration-200"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-4">
<div className={`p-3 rounded-xl ${color}`}>
<Icon className="w-6 h-6 text-white" size={24} />
</div>
<div>
<p className="text-white/60 text-sm font-medium">{title}</p>
{description && <p className="text-white/40 text-xs">{description}</p>}
</div>
</div>
<p className="text-3xl font-bold text-white mb-2">{value}</p>
{trend && trendValue && (
<div className={`flex items-center space-x-1 text-sm ${
trend === 'up' ? 'text-green-400' :
trend === 'down' ? 'text-red-400' : 'text-yellow-400'
}`}>
<TrendingUp className={`w-4 h-4 ${trend === 'down' ? 'rotate-180' : ''}`} />
<span>{trendValue}</span>
</div>
)}
</div>
</div>
</motion.div>
);
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'Beginner': return 'bg-green-500/30 text-green-400 border-green-500/40';
case 'Intermediate': return 'bg-yellow-500/30 text-yellow-400 border-yellow-500/40';
case 'Advanced': return 'bg-orange-500/30 text-orange-400 border-orange-500/40';
case 'Expert': return 'bg-red-500/30 text-red-400 border-red-500/40';
default: return 'bg-gray-500/30 text-gray-400 border-gray-500/40';
}
};
const getCategoryColor = (index: number) => {
const colors = [
'bg-blue-500/30 text-blue-400',
'bg-purple-500/30 text-purple-400',
'bg-green-500/30 text-green-400',
'bg-pink-500/30 text-pink-400',
'bg-indigo-500/30 text-indigo-400'
];
return colors[index % colors.length];
};
if (!isAuthenticated) {
return (
<div className="admin-glass-card p-8 rounded-xl text-center">
<BarChart3 className="w-16 h-16 text-white/40 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-white mb-2">Authentication Required</h3>
<p className="text-white/60">Please log in to view analytics data</p>
</div>
);
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-white flex items-center">
<BarChart3 className="w-8 h-8 mr-3 text-blue-400" />
Analytics Dashboard
</h1>
<p className="text-white/80 mt-2">Portfolio performance and user engagement metrics</p>
</div>
<div className="flex items-center space-x-3">
{/* Time Range Selector */}
<div className="flex items-center space-x-1 admin-glass-light rounded-xl p-1">
{(['7d', '30d', '90d', '1y'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
timeRange === range
? 'bg-blue-500/40 text-blue-300 shadow-lg'
: 'text-white/70 hover:text-white hover:bg-white/10'
}`}
>
{range === '7d' ? '7 Days' : range === '30d' ? '30 Days' : range === '90d' ? '90 Days' : '1 Year'}
</button>
))}
</div>
<button
onClick={fetchAnalyticsData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 admin-glass-light rounded-xl hover:scale-105 transition-all duration-200 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 text-blue-400 ${loading ? 'animate-spin' : ''}`} />
<span className="text-white font-medium">Refresh</span>
</button>
<button
onClick={() => setShowResetModal(true)}
className="flex items-center space-x-2 px-4 py-2 bg-red-600/20 text-red-400 border border-red-500/30 rounded-xl hover:bg-red-600/30 hover:scale-105 transition-all"
>
<RotateCcw className="w-4 h-4" />
<span>Reset</span>
</button>
</div>
</div>
{loading && (
<div className="admin-glass-card p-8 rounded-xl">
<div className="flex items-center justify-center space-x-3">
<RefreshCw className="w-6 h-6 text-blue-400 animate-spin" />
<span className="text-white/80 text-lg">Loading analytics data...</span>
</div>
</div>
)}
{error && (
<div className="admin-glass-card p-6 rounded-xl border border-red-500/40">
<div className="flex items-center space-x-3 text-red-300">
<Activity className="w-5 h-5" />
<span>Error: {error}</span>
</div>
</div>
)}
{data && !loading && (
<>
{/* Overview Stats */}
<div>
<h2 className="text-xl font-bold text-white mb-6 flex items-center">
<Target className="w-5 h-5 mr-2 text-purple-400" />
Overview
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<StatCard
title="Total Views"
value={data.overview.totalViews.toLocaleString()}
icon={Eye}
color="bg-blue-500/30"
trend="up"
trendValue="+12.5%"
description="All-time page views"
/>
<StatCard
title="Projects"
value={data.overview.totalProjects}
icon={Globe}
color="bg-green-500/30"
trend="up"
trendValue="+2"
description={`${data.overview.publishedProjects} published`}
/>
<StatCard
title="Engagement"
value={data.overview.totalLikes}
icon={Heart}
color="bg-pink-500/30"
trend="up"
trendValue="+8.2%"
description="Total likes & shares"
/>
<StatCard
title="Performance"
value={data.overview.avgLighthouse}
icon={Zap}
color="bg-orange-500/30"
trend="up"
trendValue="+5%"
description="Avg Lighthouse score"
/>
<StatCard
title="Bounce Rate"
value={`${data.metrics.bounceRate}%`}
icon={MousePointer}
color="bg-purple-500/30"
trend="down"
trendValue="-2.1%"
description="User retention"
/>
</div>
</div>
{/* Project Performance */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Projects */}
<div className="admin-glass-card p-6 rounded-xl">
<h3 className="text-xl font-bold text-white mb-6 flex items-center">
<Award className="w-5 h-5 mr-2 text-yellow-400" />
Top Performing Projects
</h3>
<div className="space-y-4">
{data.projects
.sort((a, b) => b.views - a.views)
.slice(0, 5)
.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center justify-between p-4 admin-glass-light rounded-xl"
>
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg flex items-center justify-center text-white font-bold">
#{index + 1}
</div>
<div>
<p className="text-white font-medium">{project.title}</p>
<p className="text-white/60 text-sm">{project.category}</p>
</div>
</div>
<div className="text-right">
<p className="text-white font-bold">{project.views.toLocaleString()}</p>
<p className="text-white/60 text-sm">views</p>
</div>
</motion.div>
))}
</div>
</div>
{/* Categories Distribution */}
<div className="admin-glass-card p-6 rounded-xl">
<h3 className="text-xl font-bold text-white mb-6 flex items-center">
<BarChart3 className="w-5 h-5 mr-2 text-green-400" />
Categories
</h3>
<div className="space-y-4">
{Object.entries(data.categories).map(([category, count], index) => (
<motion.div
key={category}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${getCategoryColor(index)}`}></div>
<span className="text-white font-medium">{category}</span>
</div>
<div className="flex items-center space-x-3">
<div className="w-32 h-2 bg-white/10 rounded-full overflow-hidden">
<div
className={`h-full ${getCategoryColor(index)} transition-all duration-500`}
style={{ width: `${(count / Math.max(...Object.values(data.categories))) * 100}%` }}
></div>
</div>
<span className="text-white/80 font-medium w-8 text-right">{count}</span>
</div>
</motion.div>
))}
</div>
</div>
</div>
{/* Difficulty & Engagement */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Difficulty Distribution */}
<div className="admin-glass-card p-6 rounded-xl">
<h3 className="text-xl font-bold text-white mb-6 flex items-center">
<Target className="w-5 h-5 mr-2 text-red-400" />
Difficulty Levels
</h3>
<div className="grid grid-cols-2 gap-4">
{Object.entries(data.difficulties).map(([difficulty, count]) => (
<motion.div
key={difficulty}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className={`p-4 rounded-xl border ${getDifficultyColor(difficulty)}`}
>
<div className="text-center">
<p className="text-2xl font-bold mb-1">{count}</p>
<p className="text-sm font-medium">{difficulty}</p>
</div>
</motion.div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="admin-glass-card p-6 rounded-xl">
<h3 className="text-xl font-bold text-white mb-6 flex items-center">
<Activity className="w-5 h-5 mr-2 text-blue-400" />
Recent Activity
</h3>
<div className="space-y-4">
{data.projects
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 4)
.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center space-x-4 p-3 admin-glass-light rounded-xl"
>
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
<div className="flex-1">
<p className="text-white font-medium text-sm">{project.title}</p>
<p className="text-white/60 text-xs">
Updated {new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center space-x-2">
{project.featured && (
<span className="px-2 py-1 bg-purple-500/20 text-purple-400 rounded-full text-xs">
Featured
</span>
)}
<span className={`px-2 py-1 rounded-full text-xs ${
project.published
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'
}`}>
{project.published ? 'Live' : 'Draft'}
</span>
</div>
</motion.div>
))}
</div>
</div>
</div>
</>
)}
{/* Reset Modal */}
{showResetModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="admin-glass-card rounded-2xl p-6 w-full max-w-md"
>
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-400" />
</div>
<div>
<h3 className="text-lg font-bold text-white">Reset Analytics Data</h3>
<p className="text-white/60 text-sm">This action cannot be undone</p>
</div>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-white/80 text-sm mb-2">Reset Type</label>
<select
value={resetType}
onChange={(e) => setResetType(e.target.value as 'all' | 'performance' | 'analytics')}
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="analytics">Analytics Only (views, likes, shares)</option>
<option value="pageviews">Page Views Only</option>
<option value="interactions">User Interactions Only</option>
<option value="performance">Performance Metrics Only</option>
<option value="all">Everything (Complete Reset)</option>
</select>
</div>
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-3">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-red-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-300">
<p className="font-medium mb-1">Warning:</p>
<p>This will permanently delete the selected analytics data. This action cannot be reversed.</p>
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setShowResetModal(false)}
disabled={resetting}
className="flex-1 px-4 py-2 admin-glass-light text-white rounded-lg hover:scale-105 transition-all disabled:opacity-50"
>
Cancel
</button>
<button
onClick={resetAnalytics}
disabled={resetting}
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg hover:scale-105 transition-all disabled:opacity-50"
>
{resetting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Resetting...</span>
</>
) : (
<>
<Trash2 className="w-4 h-4" />
<span>Reset {resetType === 'all' ? 'Everything' : 'Data'}</span>
</>
)}
</button>
</div>
</motion.div>
</div>
)}
</div>
);
}
export default AnalyticsDashboard;