fix: namespace rate limit buckets per endpoint, remove custom analytics
- Add `prefix` param to checkRateLimit/getRateLimitHeaders so each endpoint
has its own bucket (previously all shared `admin_${ip}`, causing 429s when
analytics/track incremented past n8n endpoints' lower limits)
- n8n/hardcover/currently-reading → prefix 'n8n-reading'
- n8n/status → prefix 'n8n-status'
- analytics/track → prefix 'analytics-track'
- Remove custom analytics system (AnalyticsProvider, lib/analytics,
lib/useWebVitals, all /api/analytics/* routes) — was causing 500s in
production due to missing PostgreSQL PageView table
- Remove analytics consent toggle from ConsentBanner/ConsentProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -46,13 +46,7 @@ export default function ProjectDetailClient({
|
|||||||
setCanGoBack(true);
|
setCanGoBack(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
}, []);
|
||||||
navigator.sendBeacon?.(
|
|
||||||
"/api/analytics/track",
|
|
||||||
new Blob([JSON.stringify({ type: "pageview", projectId: project.id.toString(), page: `/${locale}/projects/${project.slug}` })], { type: "application/json" }),
|
|
||||||
);
|
|
||||||
} catch {}
|
|
||||||
}, [project.id, project.slug, locale]);
|
|
||||||
|
|
||||||
const handleBack = (e: React.MouseEvent) => {
|
const handleBack = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma, projectService } from '@/lib/prisma';
|
|
||||||
import { analyticsCache } from '@/lib/redis';
|
|
||||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting - more generous for admin dashboard
|
|
||||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
if (!checkRateLimit(ip, 20, 60000)) { // 20 requests per minute
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getRateLimitHeaders(ip, 20, 60000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Admin-only endpoint: require explicit admin header AND a valid signed session token
|
|
||||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
|
||||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
|
||||||
const authError = requireSessionAuth(request);
|
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
// Check cache first (but allow bypass with cache-bust parameter)
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const bypassCache = url.searchParams.get('nocache') === 'true';
|
|
||||||
|
|
||||||
if (!bypassCache) {
|
|
||||||
const cachedStats = await analyticsCache.getOverallStats();
|
|
||||||
if (cachedStats) {
|
|
||||||
return NextResponse.json(cachedStats);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get analytics data
|
|
||||||
const projectsResult = await projectService.getAllProjects();
|
|
||||||
const projects = projectsResult.projects || projectsResult;
|
|
||||||
const performanceStats = await projectService.getPerformanceStats();
|
|
||||||
|
|
||||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
// Use DB aggregation instead of loading every PageView row into memory
|
|
||||||
const [totalViews, sessionsByIp, viewsByProjectRows] = await Promise.all([
|
|
||||||
prisma.pageView.count({ where: { timestamp: { gte: since } } }),
|
|
||||||
prisma.pageView.groupBy({
|
|
||||||
by: ['ip'],
|
|
||||||
where: {
|
|
||||||
timestamp: { gte: since },
|
|
||||||
ip: { not: null },
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
|
||||||
_min: { timestamp: true },
|
|
||||||
_max: { timestamp: true },
|
|
||||||
}),
|
|
||||||
prisma.pageView.groupBy({
|
|
||||||
by: ['projectId'],
|
|
||||||
where: {
|
|
||||||
timestamp: { gte: since },
|
|
||||||
projectId: { not: null },
|
|
||||||
},
|
|
||||||
_count: { _all: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalSessions = sessionsByIp.length;
|
|
||||||
const bouncedSessions = sessionsByIp.filter(s => (s as unknown as { _count?: { _all?: number } })._count?._all === 1).length;
|
|
||||||
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
|
||||||
|
|
||||||
const sessionDurationsMs = sessionsByIp
|
|
||||||
.map(s => {
|
|
||||||
const count = (s as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
|
||||||
if (count < 2) return 0;
|
|
||||||
const minTs = (s as unknown as { _min?: { timestamp?: Date | null } })._min?.timestamp;
|
|
||||||
const maxTs = (s as unknown as { _max?: { timestamp?: Date | null } })._max?.timestamp;
|
|
||||||
if (!minTs || !maxTs) return 0;
|
|
||||||
return maxTs.getTime() - minTs.getTime();
|
|
||||||
})
|
|
||||||
.filter(ms => ms > 0);
|
|
||||||
|
|
||||||
const avgSessionDuration = sessionDurationsMs.length > 0
|
|
||||||
? Math.round(sessionDurationsMs.reduce((a, b) => a + b, 0) / sessionDurationsMs.length / 1000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const totalUsers = totalSessions;
|
|
||||||
|
|
||||||
const viewsByProject = viewsByProjectRows.reduce((acc, row) => {
|
|
||||||
const projectId = row.projectId as number | null;
|
|
||||||
if (projectId != null) {
|
|
||||||
acc[projectId] = (row as unknown as { _count?: { _all?: number } })._count?._all ?? 0;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<number, number>);
|
|
||||||
|
|
||||||
// Calculate analytics metrics
|
|
||||||
const analytics = {
|
|
||||||
overview: {
|
|
||||||
totalProjects: projects.length,
|
|
||||||
publishedProjects: projects.filter(p => p.published).length,
|
|
||||||
featuredProjects: projects.filter(p => p.featured).length,
|
|
||||||
totalViews, // Real views from PageView table
|
|
||||||
totalLikes: 0, // Not implemented - no like buttons
|
|
||||||
totalShares: 0, // Not implemented - no share buttons
|
|
||||||
avgLighthouse: (() => {
|
|
||||||
// Only calculate if we have real performance data (not defaults)
|
|
||||||
const projectsWithPerf = projects.filter(p => {
|
|
||||||
const perf = (p.performance as Record<string, unknown>) || {};
|
|
||||||
const lighthouse = perf.lighthouse as number || 0;
|
|
||||||
return lighthouse > 0; // Only count projects with actual performance data
|
|
||||||
});
|
|
||||||
return projectsWithPerf.length > 0
|
|
||||||
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
|
||||||
: 0;
|
|
||||||
})()
|
|
||||||
},
|
|
||||||
projects: projects.map(project => ({
|
|
||||||
id: project.id,
|
|
||||||
title: project.title,
|
|
||||||
category: project.category,
|
|
||||||
difficulty: project.difficulty,
|
|
||||||
views: viewsByProject[project.id] || 0, // Only real views from PageView table
|
|
||||||
likes: 0, // Not implemented
|
|
||||||
shares: 0, // Not implemented
|
|
||||||
lighthouse: (() => {
|
|
||||||
const perf = (project.performance as Record<string, unknown>) || {};
|
|
||||||
const score = perf.lighthouse as number || 0;
|
|
||||||
return score > 0 ? score : 0; // Only return if we have real data
|
|
||||||
})(),
|
|
||||||
published: project.published,
|
|
||||||
featured: project.featured,
|
|
||||||
createdAt: project.createdAt,
|
|
||||||
updatedAt: project.updatedAt
|
|
||||||
})),
|
|
||||||
categories: performanceStats.byCategory,
|
|
||||||
difficulties: performanceStats.byDifficulty,
|
|
||||||
performance: {
|
|
||||||
avgLighthouse: (() => {
|
|
||||||
const projectsWithPerf = projects.filter(p => {
|
|
||||||
const perf = (p.performance as Record<string, unknown>) || {};
|
|
||||||
return (perf.lighthouse as number || 0) > 0;
|
|
||||||
});
|
|
||||||
return projectsWithPerf.length > 0
|
|
||||||
? Math.round(projectsWithPerf.reduce((sum, p) => sum + ((p.performance as Record<string, unknown>)?.lighthouse as number || 0), 0) / projectsWithPerf.length)
|
|
||||||
: 0;
|
|
||||||
})(),
|
|
||||||
totalViews, // Real total views
|
|
||||||
totalLikes: 0,
|
|
||||||
totalShares: 0
|
|
||||||
},
|
|
||||||
metrics: {
|
|
||||||
bounceRate,
|
|
||||||
avgSessionDuration,
|
|
||||||
pagesPerSession: totalSessions > 0 ? (totalViews / totalSessions).toFixed(1) : '0',
|
|
||||||
newUsers: totalUsers,
|
|
||||||
totalUsers
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Cache the results
|
|
||||||
await analyticsCache.setOverallStats(analytics);
|
|
||||||
|
|
||||||
return NextResponse.json(analytics);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Analytics dashboard error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch analytics data' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { requireSessionAuth } from '@/lib/auth';
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Admin-only endpoint
|
|
||||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
|
||||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
|
||||||
const authError = requireSessionAuth(request);
|
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
// Get performance data from database
|
|
||||||
const pageViews = await prisma.pageView.findMany({
|
|
||||||
orderBy: { timestamp: 'desc' },
|
|
||||||
take: 1000 // Last 1000 page views
|
|
||||||
});
|
|
||||||
|
|
||||||
const userInteractions = await prisma.userInteraction.findMany({
|
|
||||||
orderBy: { timestamp: 'desc' },
|
|
||||||
take: 1000 // Last 1000 interactions
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all projects for performance data
|
|
||||||
const projects = await prisma.project.findMany();
|
|
||||||
|
|
||||||
// Calculate real performance metrics from projects
|
|
||||||
const projectsWithPerformance = projects.map(p => ({
|
|
||||||
id: p.id,
|
|
||||||
title: p.title,
|
|
||||||
lighthouse: ((p.performance as Record<string, unknown>)?.lighthouse as number) || 0,
|
|
||||||
loadTime: ((p.performance as Record<string, unknown>)?.loadTime as number) || 0,
|
|
||||||
fcp: ((p.performance as Record<string, unknown>)?.firstContentfulPaint as number) || 0,
|
|
||||||
lcp: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.lcp as number || 0,
|
|
||||||
cls: ((p.performance as Record<string, unknown>)?.coreWebVitals as Record<string, unknown>)?.cls as number || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate average lighthouse score (currently unused but kept for future use)
|
|
||||||
const _avgLighthouse = projectsWithPerformance.length > 0
|
|
||||||
? Math.round(projectsWithPerformance.reduce((sum, p) => sum + p.lighthouse, 0) / projectsWithPerformance.length)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Calculate bounce rate from page views
|
|
||||||
const pageViewsByIP = pageViews.reduce((acc, pv) => {
|
|
||||||
const ip = pv.ip || 'unknown';
|
|
||||||
if (!acc[ip]) acc[ip] = [];
|
|
||||||
acc[ip].push(pv);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, typeof pageViews>);
|
|
||||||
|
|
||||||
const totalSessions = Object.keys(pageViewsByIP).length;
|
|
||||||
const bouncedSessions = Object.values(pageViewsByIP).filter(session => session.length === 1).length;
|
|
||||||
const bounceRate = totalSessions > 0 ? Math.round((bouncedSessions / totalSessions) * 100) : 0;
|
|
||||||
|
|
||||||
// Calculate average session duration
|
|
||||||
const sessionDurations = Object.values(pageViewsByIP)
|
|
||||||
.map(session => {
|
|
||||||
if (session.length < 2) return 0;
|
|
||||||
const sorted = session.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
||||||
return sorted[sorted.length - 1].timestamp.getTime() - sorted[0].timestamp.getTime();
|
|
||||||
})
|
|
||||||
.filter(d => d > 0);
|
|
||||||
const avgSessionDuration = sessionDurations.length > 0
|
|
||||||
? Math.round(sessionDurations.reduce((a, b) => a + b, 0) / sessionDurations.length / 1000) // in seconds
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Calculate pages per session
|
|
||||||
const pagesPerSession = totalSessions > 0 ? (pageViews.length / totalSessions).toFixed(1) : '0';
|
|
||||||
|
|
||||||
// Calculate performance metrics
|
|
||||||
const performance = {
|
|
||||||
avgLighthouse: (() => {
|
|
||||||
const projectsWithPerf = projects.filter(p => {
|
|
||||||
const perf = (p.performance as Record<string, unknown>) || {};
|
|
||||||
return (perf.lighthouse as number || 0) > 0;
|
|
||||||
});
|
|
||||||
return projectsWithPerf.length > 0
|
|
||||||
? Math.round(projectsWithPerf.reduce((sum, p) => {
|
|
||||||
const perf = (p.performance as Record<string, unknown>) || {};
|
|
||||||
return sum + (perf.lighthouse as number || 0);
|
|
||||||
}, 0) / projectsWithPerf.length)
|
|
||||||
: 0;
|
|
||||||
})(),
|
|
||||||
totalViews: pageViews.length,
|
|
||||||
metrics: {
|
|
||||||
bounceRate,
|
|
||||||
avgSessionDuration: avgSessionDuration,
|
|
||||||
pagesPerSession: parseFloat(pagesPerSession),
|
|
||||||
newUsers: new Set(pageViews.map(pv => pv.ip).filter(Boolean)).size
|
|
||||||
},
|
|
||||||
pageViews: {
|
|
||||||
total: pageViews.length,
|
|
||||||
last24h: pageViews.filter(pv => {
|
|
||||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(pv.timestamp) > dayAgo;
|
|
||||||
}).length,
|
|
||||||
last7d: pageViews.filter(pv => {
|
|
||||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(pv.timestamp) > weekAgo;
|
|
||||||
}).length,
|
|
||||||
last30d: pageViews.filter(pv => {
|
|
||||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(pv.timestamp) > monthAgo;
|
|
||||||
}).length
|
|
||||||
},
|
|
||||||
interactions: {
|
|
||||||
total: userInteractions.length,
|
|
||||||
last24h: userInteractions.filter(ui => {
|
|
||||||
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(ui.timestamp) > dayAgo;
|
|
||||||
}).length,
|
|
||||||
last7d: userInteractions.filter(ui => {
|
|
||||||
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(ui.timestamp) > weekAgo;
|
|
||||||
}).length,
|
|
||||||
last30d: userInteractions.filter(ui => {
|
|
||||||
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(ui.timestamp) > monthAgo;
|
|
||||||
}).length
|
|
||||||
},
|
|
||||||
topPages: pageViews.reduce((acc, pv) => {
|
|
||||||
acc[pv.page] = (acc[pv.page] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>),
|
|
||||||
topInteractions: userInteractions.reduce((acc, ui) => {
|
|
||||||
acc[ui.type] = (acc[ui.type] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>)
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(performance);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Performance analytics error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to fetch performance data' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { analyticsCache } from '@/lib/redis';
|
|
||||||
import { requireSessionAuth, checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting
|
|
||||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
if (!checkRateLimit(ip, 3, 300000)) { // 3 requests per 5 minutes - more restrictive for reset
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getRateLimitHeaders(ip, 3, 300000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check admin authentication
|
|
||||||
const isAdminRequest = request.headers.get('x-admin-request') === 'true';
|
|
||||||
if (!isAdminRequest) return NextResponse.json({ error: 'Admin access required' }, { status: 403 });
|
|
||||||
const authError = requireSessionAuth(request);
|
|
||||||
if (authError) return authError;
|
|
||||||
|
|
||||||
const { type } = await request.json();
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'analytics':
|
|
||||||
// Reset all project analytics (view counts in project.analytics JSON)
|
|
||||||
const projects = await prisma.project.findMany();
|
|
||||||
for (const project of projects) {
|
|
||||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
|
||||||
await prisma.project.update({
|
|
||||||
where: { id: project.id },
|
|
||||||
data: {
|
|
||||||
analytics: {
|
|
||||||
...analytics,
|
|
||||||
views: 0,
|
|
||||||
likes: 0,
|
|
||||||
shares: 0,
|
|
||||||
comments: 0,
|
|
||||||
bookmarks: 0,
|
|
||||||
clickThroughs: 0,
|
|
||||||
bounceRate: 0,
|
|
||||||
avgTimeOnPage: 0,
|
|
||||||
uniqueVisitors: 0,
|
|
||||||
returningVisitors: 0,
|
|
||||||
conversionRate: 0,
|
|
||||||
socialShares: {
|
|
||||||
twitter: 0,
|
|
||||||
linkedin: 0,
|
|
||||||
facebook: 0,
|
|
||||||
github: 0
|
|
||||||
},
|
|
||||||
deviceStats: {
|
|
||||||
mobile: 0,
|
|
||||||
desktop: 0,
|
|
||||||
tablet: 0
|
|
||||||
},
|
|
||||||
locationStats: {},
|
|
||||||
referrerStats: {},
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'pageviews':
|
|
||||||
// Clear PageView table
|
|
||||||
await prisma.pageView.deleteMany({});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'interactions':
|
|
||||||
// Clear UserInteraction table
|
|
||||||
await prisma.userInteraction.deleteMany({});
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'performance':
|
|
||||||
// Reset performance metrics (preserve structure)
|
|
||||||
const projectsForPerf = await prisma.project.findMany();
|
|
||||||
for (const project of projectsForPerf) {
|
|
||||||
const perf = (project.performance as Record<string, unknown>) || {};
|
|
||||||
await prisma.project.update({
|
|
||||||
where: { id: project.id },
|
|
||||||
data: {
|
|
||||||
performance: {
|
|
||||||
...perf,
|
|
||||||
lighthouse: 0,
|
|
||||||
loadTime: 0,
|
|
||||||
firstContentfulPaint: 0,
|
|
||||||
largestContentfulPaint: 0,
|
|
||||||
cumulativeLayoutShift: 0,
|
|
||||||
totalBlockingTime: 0,
|
|
||||||
speedIndex: 0,
|
|
||||||
accessibility: 0,
|
|
||||||
bestPractices: 0,
|
|
||||||
seo: 0,
|
|
||||||
performanceScore: 0,
|
|
||||||
mobileScore: 0,
|
|
||||||
desktopScore: 0,
|
|
||||||
coreWebVitals: {
|
|
||||||
lcp: 0,
|
|
||||||
fid: 0,
|
|
||||||
cls: 0
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'all':
|
|
||||||
// Reset everything
|
|
||||||
const allProjects = await prisma.project.findMany();
|
|
||||||
await Promise.all([
|
|
||||||
// Reset analytics and performance for each project (preserve structure)
|
|
||||||
...allProjects.map(project => {
|
|
||||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
|
||||||
const perf = (project.performance as Record<string, unknown>) || {};
|
|
||||||
return prisma.project.update({
|
|
||||||
where: { id: project.id },
|
|
||||||
data: {
|
|
||||||
analytics: {
|
|
||||||
...analytics,
|
|
||||||
views: 0,
|
|
||||||
likes: 0,
|
|
||||||
shares: 0,
|
|
||||||
comments: 0,
|
|
||||||
bookmarks: 0,
|
|
||||||
clickThroughs: 0,
|
|
||||||
bounceRate: 0,
|
|
||||||
avgTimeOnPage: 0,
|
|
||||||
uniqueVisitors: 0,
|
|
||||||
returningVisitors: 0,
|
|
||||||
conversionRate: 0,
|
|
||||||
socialShares: {
|
|
||||||
twitter: 0,
|
|
||||||
linkedin: 0,
|
|
||||||
facebook: 0,
|
|
||||||
github: 0
|
|
||||||
},
|
|
||||||
deviceStats: {
|
|
||||||
mobile: 0,
|
|
||||||
desktop: 0,
|
|
||||||
tablet: 0
|
|
||||||
},
|
|
||||||
locationStats: {},
|
|
||||||
referrerStats: {},
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
performance: {
|
|
||||||
...perf,
|
|
||||||
lighthouse: 0,
|
|
||||||
loadTime: 0,
|
|
||||||
firstContentfulPaint: 0,
|
|
||||||
largestContentfulPaint: 0,
|
|
||||||
cumulativeLayoutShift: 0,
|
|
||||||
totalBlockingTime: 0,
|
|
||||||
speedIndex: 0,
|
|
||||||
accessibility: 0,
|
|
||||||
bestPractices: 0,
|
|
||||||
seo: 0,
|
|
||||||
performanceScore: 0,
|
|
||||||
mobileScore: 0,
|
|
||||||
desktopScore: 0,
|
|
||||||
coreWebVitals: {
|
|
||||||
lcp: 0,
|
|
||||||
fid: 0,
|
|
||||||
cls: 0
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
// Clear tracking tables
|
|
||||||
prisma.pageView.deleteMany({}),
|
|
||||||
prisma.userInteraction.deleteMany({})
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid reset type. Use: analytics, pageviews, interactions, performance, or all' },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear cache
|
|
||||||
await analyticsCache.clearAll();
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: `Successfully reset ${type} data`,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Analytics reset error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to reset analytics data' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting for POST requests
|
|
||||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
if (!checkRateLimit(ip, 30, 60000)) { // 30 requests per minute for analytics
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getRateLimitHeaders(ip, 30, 60000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
// Log performance metrics (you can extend this to store in database)
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('Performance Metric:', {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// You could store this in a database or send to external service
|
|
||||||
// For now, we'll just log it since Umami handles the main analytics
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Analytics API Error:', error);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to process analytics data' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json({
|
|
||||||
message: 'Analytics API is running',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { prisma } from '@/lib/prisma';
|
|
||||||
import { checkRateLimit, getRateLimitHeaders } from '@/lib/auth';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
// Rate limiting
|
|
||||||
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
|
|
||||||
if (!checkRateLimit(ip, 100, 60000)) { // 100 requests per minute for tracking
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'Rate limit exceeded' }),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...getRateLimitHeaders(ip, 100, 60000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const { type, projectId, page, performance, session } = body;
|
|
||||||
const userAgent = request.headers.get('user-agent') || undefined;
|
|
||||||
const referrer = request.headers.get('referer') || undefined;
|
|
||||||
|
|
||||||
// Track page view
|
|
||||||
if (type === 'pageview' && page) {
|
|
||||||
let projectIdNum: number | null = null;
|
|
||||||
if (projectId != null) {
|
|
||||||
const raw = projectId.toString();
|
|
||||||
const parsed = parseInt(raw, 10);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
projectIdNum = parsed;
|
|
||||||
} else {
|
|
||||||
const bySlug = await prisma.project.findFirst({
|
|
||||||
where: { slug: raw },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
projectIdNum = bySlug?.id ?? null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create page view record
|
|
||||||
await prisma.pageView.create({
|
|
||||||
data: {
|
|
||||||
projectId: projectIdNum,
|
|
||||||
page,
|
|
||||||
ip,
|
|
||||||
userAgent,
|
|
||||||
referrer
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update project analytics if projectId exists
|
|
||||||
if (projectIdNum) {
|
|
||||||
const project = await prisma.project.findUnique({
|
|
||||||
where: { id: projectIdNum }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
|
||||||
const currentViews = (analytics.views as number) || 0;
|
|
||||||
|
|
||||||
await prisma.project.update({
|
|
||||||
where: { id: projectIdNum },
|
|
||||||
data: {
|
|
||||||
analytics: {
|
|
||||||
...analytics,
|
|
||||||
views: currentViews + 1,
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track performance metrics
|
|
||||||
if (type === 'performance' && performance) {
|
|
||||||
// Try to get projectId from page path if not provided
|
|
||||||
let projectIdNum: number | null = null;
|
|
||||||
if (projectId) {
|
|
||||||
projectIdNum = parseInt(projectId.toString());
|
|
||||||
} else if (page) {
|
|
||||||
// Try to extract from page path like /projects/123 or /projects/slug
|
|
||||||
const match = page.match(/\/projects\/(\d+)/);
|
|
||||||
if (match) {
|
|
||||||
projectIdNum = parseInt(match[1]);
|
|
||||||
} else {
|
|
||||||
// Try to find by slug
|
|
||||||
const slugMatch = page.match(/\/projects\/([^\/]+)/);
|
|
||||||
if (slugMatch) {
|
|
||||||
const slug = slugMatch[1];
|
|
||||||
const project = await prisma.project.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ id: parseInt(slug) || 0 },
|
|
||||||
{ slug }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (project) projectIdNum = project.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (projectIdNum) {
|
|
||||||
const project = await prisma.project.findUnique({
|
|
||||||
where: { id: projectIdNum }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (project) {
|
|
||||||
const perf = (project.performance as Record<string, unknown>) || {};
|
|
||||||
const analytics = (project.analytics as Record<string, unknown>) || {};
|
|
||||||
|
|
||||||
// Calculate lighthouse score from web vitals
|
|
||||||
const lcp = performance.lcp || 0;
|
|
||||||
const fid = performance.fid || 0;
|
|
||||||
const cls = performance.cls || 0;
|
|
||||||
const fcp = performance.fcp || 0;
|
|
||||||
const ttfb = performance.ttfb || 0;
|
|
||||||
|
|
||||||
// Only calculate lighthouse score if we have real web vitals data
|
|
||||||
// Check if we have at least LCP and FCP (most important metrics)
|
|
||||||
if (lcp > 0 || fcp > 0) {
|
|
||||||
// Simple lighthouse score calculation (0-100)
|
|
||||||
let lighthouseScore = 100;
|
|
||||||
if (lcp > 4000) lighthouseScore -= 25;
|
|
||||||
else if (lcp > 2500) lighthouseScore -= 15;
|
|
||||||
if (fid > 300) lighthouseScore -= 25;
|
|
||||||
else if (fid > 100) lighthouseScore -= 15;
|
|
||||||
if (cls > 0.25) lighthouseScore -= 25;
|
|
||||||
else if (cls > 0.1) lighthouseScore -= 15;
|
|
||||||
if (fcp > 3000) lighthouseScore -= 15;
|
|
||||||
if (ttfb > 800) lighthouseScore -= 10;
|
|
||||||
|
|
||||||
lighthouseScore = Math.max(0, Math.min(100, lighthouseScore));
|
|
||||||
|
|
||||||
await prisma.project.update({
|
|
||||||
where: { id: projectIdNum },
|
|
||||||
data: {
|
|
||||||
performance: {
|
|
||||||
...perf,
|
|
||||||
lighthouse: lighthouseScore,
|
|
||||||
loadTime: performance.loadTime || perf.loadTime || 0,
|
|
||||||
firstContentfulPaint: fcp || perf.firstContentfulPaint || 0,
|
|
||||||
largestContentfulPaint: lcp || perf.largestContentfulPaint || 0,
|
|
||||||
cumulativeLayoutShift: cls || perf.cumulativeLayoutShift || 0,
|
|
||||||
totalBlockingTime: performance.tbt || perf.totalBlockingTime || 0,
|
|
||||||
speedIndex: performance.si || perf.speedIndex || 0,
|
|
||||||
coreWebVitals: {
|
|
||||||
lcp: lcp || (perf.coreWebVitals as Record<string, unknown>)?.lcp || 0,
|
|
||||||
fid: fid || (perf.coreWebVitals as Record<string, unknown>)?.fid || 0,
|
|
||||||
cls: cls || (perf.coreWebVitals as Record<string, unknown>)?.cls || 0
|
|
||||||
},
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
},
|
|
||||||
analytics: {
|
|
||||||
...analytics,
|
|
||||||
lastUpdated: new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track session data (for bounce rate calculation)
|
|
||||||
if (type === 'session' && session) {
|
|
||||||
// Store session data in a way that allows bounce rate calculation
|
|
||||||
// A bounce is a session with only one pageview
|
|
||||||
// We'll track this via PageView records and calculate bounce rate from them
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Analytics tracking error:', error);
|
|
||||||
}
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to track analytics' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ export async function GET(request: NextRequest) {
|
|||||||
: ip;
|
: ip;
|
||||||
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
|
const maxPerMinute = process.env.NODE_ENV === "development" ? 60 : 10;
|
||||||
|
|
||||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
|
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-reading')) { // requests per minute
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function GET(request: NextRequest) {
|
|||||||
: ip;
|
: ip;
|
||||||
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
|
const maxPerMinute = process.env.NODE_ENV === "development" ? 300 : 30;
|
||||||
|
|
||||||
if (!checkRateLimit(rateKey, maxPerMinute, 60000)) { // requests per minute
|
if (!checkRateLimit(rateKey, maxPerMinute, 60000, 'n8n-status')) { // requests per minute
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||||
{ status: 429 }
|
{ status: 429 }
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { usePathname } from "next/navigation";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ToastProvider } from "@/components/Toast";
|
import { ToastProvider } from "@/components/Toast";
|
||||||
import ErrorBoundary from "@/components/ErrorBoundary";
|
import ErrorBoundary from "@/components/ErrorBoundary";
|
||||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
import { ConsentProvider } from "./ConsentProvider";
|
||||||
import { ConsentProvider, useConsent } from "./ConsentProvider";
|
|
||||||
import { ThemeProvider } from "./ThemeProvider";
|
import { ThemeProvider } from "./ThemeProvider";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
@@ -97,12 +96,7 @@ function GatedProviders({
|
|||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
is404Page: boolean;
|
is404Page: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { consent } = useConsent();
|
return (
|
||||||
|
|
||||||
// If consent is not decided yet, treat optional features as off
|
|
||||||
const analyticsEnabled = !!consent?.analytics;
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
{mounted && <BackgroundBlobs />}
|
{mounted && <BackgroundBlobs />}
|
||||||
@@ -110,6 +104,4 @@ function GatedProviders({
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
|
||||||
return analyticsEnabled ? <AnalyticsProvider>{content}</AnalyticsProvider> : content;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslations } from "next-intl";
|
|||||||
|
|
||||||
export default function ConsentBanner() {
|
export default function ConsentBanner() {
|
||||||
const { consent, ready, setConsent } = useConsent();
|
const { consent, ready, setConsent } = useConsent();
|
||||||
const [draft, setDraft] = useState<ConsentState>({ analytics: false, chat: false });
|
const [draft, setDraft] = useState<ConsentState>({ chat: false });
|
||||||
const [minimized, setMinimized] = useState(false);
|
const [minimized, setMinimized] = useState(false);
|
||||||
const t = useTranslations("consent");
|
const t = useTranslations("consent");
|
||||||
|
|
||||||
@@ -19,7 +19,6 @@ export default function ConsentBanner() {
|
|||||||
title: t("title"),
|
title: t("title"),
|
||||||
description: t("description"),
|
description: t("description"),
|
||||||
essential: t("essential"),
|
essential: t("essential"),
|
||||||
analytics: t("analytics"),
|
|
||||||
chat: t("chat"),
|
chat: t("chat"),
|
||||||
alwaysOn: t("alwaysOn"),
|
alwaysOn: t("alwaysOn"),
|
||||||
acceptAll: t("acceptAll"),
|
acceptAll: t("acceptAll"),
|
||||||
@@ -68,16 +67,6 @@ export default function ConsentBanner() {
|
|||||||
<div className="text-[11px] text-stone-500">{s.alwaysOn}</div>
|
<div className="text-[11px] text-stone-500">{s.alwaysOn}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-3 py-1">
|
|
||||||
<span className="text-sm font-semibold text-stone-800">{s.analytics}</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={draft.analytics}
|
|
||||||
onChange={(e) => setDraft((p) => ({ ...p, analytics: e.target.checked }))}
|
|
||||||
className="w-4 h-4 accent-stone-900"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center justify-between gap-3 py-1">
|
<label className="flex items-center justify-between gap-3 py-1">
|
||||||
<span className="text-sm font-semibold text-stone-800">{s.chat}</span>
|
<span className="text-sm font-semibold text-stone-800">{s.chat}</span>
|
||||||
<input
|
<input
|
||||||
@@ -91,7 +80,7 @@ export default function ConsentBanner() {
|
|||||||
|
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setConsent({ analytics: true, chat: true })}
|
onClick={() => setConsent({ chat: true })}
|
||||||
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
|
className="px-4 py-2 rounded-xl bg-stone-900 text-stone-50 font-semibold hover:bg-stone-800 transition-colors"
|
||||||
>
|
>
|
||||||
{s.acceptAll}
|
{s.acceptAll}
|
||||||
@@ -103,7 +92,7 @@ export default function ConsentBanner() {
|
|||||||
{s.acceptSelected}
|
{s.acceptSelected}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setConsent({ analytics: false, chat: false })}
|
onClick={() => setConsent({ chat: false })}
|
||||||
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
|
className="px-4 py-2 rounded-xl bg-transparent text-stone-600 font-semibold hover:text-stone-900 transition-colors"
|
||||||
>
|
>
|
||||||
{s.rejectAll}
|
{s.rejectAll}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
export type ConsentState = {
|
export type ConsentState = {
|
||||||
analytics: boolean;
|
|
||||||
chat: boolean;
|
chat: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ function readConsentFromCookie(): ConsentState | null {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(value) as Partial<ConsentState>;
|
const parsed = JSON.parse(value) as Partial<ConsentState>;
|
||||||
return {
|
return {
|
||||||
analytics: !!parsed.analytics,
|
|
||||||
chat: !!parsed.chat,
|
chat: !!parsed.chat,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,13 +2,6 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
// Lazy load providers to avoid webpack module resolution issues
|
|
||||||
const AnalyticsProvider = React.lazy(() =>
|
|
||||||
import("@/components/AnalyticsProvider").then((mod) => ({
|
|
||||||
default: mod.AnalyticsProvider,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const ToastProvider = React.lazy(() =>
|
const ToastProvider = React.lazy(() =>
|
||||||
import("@/components/Toast").then((mod) => ({
|
import("@/components/Toast").then((mod) => ({
|
||||||
default: mod.ToastProvider,
|
default: mod.ToastProvider,
|
||||||
@@ -38,14 +31,11 @@ export default function RootProviders({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
|
<React.Suspense fallback={<div className="relative z-10">{children}</div>}>
|
||||||
<AnalyticsProvider>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<BackgroundBlobs />
|
||||||
<BackgroundBlobs />
|
<div className="relative z-10">{children}</div>
|
||||||
<div className="relative z-10">{children}</div>
|
<ChatWidget />
|
||||||
<ChatWidget />
|
</ToastProvider>
|
||||||
</ToastProvider>
|
|
||||||
</AnalyticsProvider>
|
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,25 +41,6 @@ const ProjectDetail = () => {
|
|||||||
const loadedProject = data.projects[0];
|
const loadedProject = data.projects[0];
|
||||||
setProject(loadedProject);
|
setProject(loadedProject);
|
||||||
|
|
||||||
// Track page view
|
|
||||||
try {
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'pageview',
|
|
||||||
projectId: loadedProject.id.toString(),
|
|
||||||
page: `/projects/${slug}`
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (trackError) {
|
|
||||||
// Silently fail tracking
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error tracking page view:', trackError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { useWebVitals } from '@/lib/useWebVitals';
|
|
||||||
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
|
||||||
import { debounce } from '@/lib/utils';
|
|
||||||
|
|
||||||
interface AnalyticsProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
|
||||||
const hasTrackedInitialView = useRef(false);
|
|
||||||
const hasTrackedPerformance = useRef(false);
|
|
||||||
const currentPath = useRef('');
|
|
||||||
|
|
||||||
// Initialize Web Vitals tracking - wrapped to prevent crashes
|
|
||||||
// Hooks must be called unconditionally, but the hook itself handles errors
|
|
||||||
useWebVitals();
|
|
||||||
|
|
||||||
// Track page view - memoized to prevent recreation
|
|
||||||
const trackPageView = useCallback(async () => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const path = window.location.pathname;
|
|
||||||
|
|
||||||
// Only track if path has changed (prevents duplicate tracking)
|
|
||||||
if (currentPath.current === path && hasTrackedInitialView.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPath.current = path;
|
|
||||||
hasTrackedInitialView.current = true;
|
|
||||||
|
|
||||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
||||||
const projectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
|
|
||||||
// Track to Umami (if available)
|
|
||||||
trackEvent('page-view', {
|
|
||||||
url: path,
|
|
||||||
referrer: document.referrer,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track to our API - single call
|
|
||||||
try {
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'pageview',
|
|
||||||
projectId: projectId,
|
|
||||||
page: path
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error tracking page view:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Wrap entire effect in try-catch to prevent any errors from breaking the app
|
|
||||||
try {
|
|
||||||
// Track page load performance - wrapped in try-catch
|
|
||||||
try {
|
|
||||||
trackPageLoad();
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking page load:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track initial page view
|
|
||||||
trackPageView();
|
|
||||||
|
|
||||||
// Track performance metrics to our API - only once
|
|
||||||
const trackPerformanceToAPI = async () => {
|
|
||||||
// Prevent duplicate tracking
|
|
||||||
if (hasTrackedPerformance.current) return;
|
|
||||||
hasTrackedPerformance.current = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current page path to extract project ID if on project page
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
||||||
const projectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
|
|
||||||
// Wait for page to fully load
|
|
||||||
setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined;
|
|
||||||
const paintEntries = performance.getEntriesByType('paint');
|
|
||||||
const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
|
|
||||||
|
|
||||||
const fcp = paintEntries.find((e: PerformanceEntry) => e.name === 'first-contentful-paint');
|
|
||||||
const lcp = lcpEntries.length > 0 ? lcpEntries[lcpEntries.length - 1] : undefined;
|
|
||||||
|
|
||||||
const performanceData = {
|
|
||||||
loadTime: navigation && navigation.loadEventEnd && navigation.fetchStart ? navigation.loadEventEnd - navigation.fetchStart : 0,
|
|
||||||
fcp: fcp ? fcp.startTime : 0,
|
|
||||||
lcp: lcp ? lcp.startTime : 0,
|
|
||||||
ttfb: navigation && navigation.responseStart && navigation.fetchStart ? navigation.responseStart - navigation.fetchStart : 0,
|
|
||||||
cls: 0, // Will be updated by CLS observer
|
|
||||||
fid: 0, // Will be updated by FID observer
|
|
||||||
si: 0 // Speed Index - would need to calculate
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send performance data - single call
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'performance',
|
|
||||||
projectId: projectId,
|
|
||||||
page: path,
|
|
||||||
performance: performanceData
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - performance tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error collecting performance data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 2500); // Wait 2.5 seconds for page to stabilize
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error tracking performance:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track performance after page load
|
|
||||||
if (document.readyState === 'complete') {
|
|
||||||
trackPerformanceToAPI();
|
|
||||||
} else {
|
|
||||||
window.addEventListener('load', trackPerformanceToAPI, { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track route changes (for SPA navigation) - debounced
|
|
||||||
const handleRouteChange = debounce(() => {
|
|
||||||
// Track new page view (trackPageView will handle path change detection)
|
|
||||||
trackPageView();
|
|
||||||
trackPageLoad();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
// Listen for popstate events (back/forward navigation)
|
|
||||||
window.addEventListener('popstate', handleRouteChange);
|
|
||||||
|
|
||||||
// Track user interactions - debounced to prevent spam
|
|
||||||
const handleClick = debounce((event: unknown) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const mouseEvent = event as MouseEvent;
|
|
||||||
const target = mouseEvent.target as HTMLElement | null;
|
|
||||||
if (!target) return;
|
|
||||||
|
|
||||||
const element = target.tagName ? target.tagName.toLowerCase() : 'unknown';
|
|
||||||
const className = target.className;
|
|
||||||
const id = target.id;
|
|
||||||
|
|
||||||
trackEvent('click', {
|
|
||||||
element,
|
|
||||||
className: (typeof className === 'string' && className) ? className.split(' ')[0] : undefined,
|
|
||||||
id: id || undefined,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - click tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking click:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Track form submissions
|
|
||||||
const handleSubmit = (event: SubmitEvent) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
const form = event.target as HTMLFormElement | null;
|
|
||||||
if (!form) return;
|
|
||||||
|
|
||||||
trackEvent('form-submit', {
|
|
||||||
formId: form.id || undefined,
|
|
||||||
formClass: form.className || undefined,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - form tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking form submit:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track scroll depth - debounced
|
|
||||||
let maxScrollDepth = 0;
|
|
||||||
const firedScrollMilestones = new Set<number>();
|
|
||||||
const handleScroll = debounce(() => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const scrollHeight = document.documentElement.scrollHeight;
|
|
||||||
const innerHeight = window.innerHeight;
|
|
||||||
|
|
||||||
if (scrollHeight <= innerHeight) return; // No scrollable content
|
|
||||||
|
|
||||||
const scrollDepth = Math.round(
|
|
||||||
(window.scrollY / (scrollHeight - innerHeight)) * 100
|
|
||||||
);
|
|
||||||
|
|
||||||
if (scrollDepth > maxScrollDepth) maxScrollDepth = scrollDepth;
|
|
||||||
|
|
||||||
// Track each milestone once (avoid spamming events on every scroll tick)
|
|
||||||
const milestones = [25, 50, 75, 90];
|
|
||||||
for (const milestone of milestones) {
|
|
||||||
if (maxScrollDepth >= milestone && !firedScrollMilestones.has(milestone)) {
|
|
||||||
firedScrollMilestones.add(milestone);
|
|
||||||
trackEvent('scroll-depth', { depth: milestone, url: window.location.pathname });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - scroll tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking scroll:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// Add event listeners
|
|
||||||
document.addEventListener('click', handleClick);
|
|
||||||
document.addEventListener('submit', handleSubmit);
|
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
||||||
|
|
||||||
// Track errors
|
|
||||||
const handleError = (event: ErrorEvent) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('error', {
|
|
||||||
message: event.message || 'Unknown error',
|
|
||||||
filename: event.filename || undefined,
|
|
||||||
lineno: event.lineno || undefined,
|
|
||||||
colno: event.colno || undefined,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - error tracking should not cause more errors
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking error event:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
||||||
try {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('unhandled-rejection', {
|
|
||||||
reason: event.reason?.toString() || 'Unknown rejection',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - error tracking should not cause more errors
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking unhandled rejection:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('error', handleError);
|
|
||||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
try {
|
|
||||||
// Cancel any pending debounced calls to prevent memory leaks
|
|
||||||
handleRouteChange.cancel();
|
|
||||||
handleClick.cancel();
|
|
||||||
handleScroll.cancel();
|
|
||||||
|
|
||||||
// Remove event listeners
|
|
||||||
window.removeEventListener('load', trackPerformanceToAPI);
|
|
||||||
window.removeEventListener('popstate', handleRouteChange);
|
|
||||||
document.removeEventListener('click', handleClick);
|
|
||||||
document.removeEventListener('submit', handleSubmit);
|
|
||||||
window.removeEventListener('scroll', handleScroll);
|
|
||||||
window.removeEventListener('error', handleError);
|
|
||||||
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
|
|
||||||
} catch {
|
|
||||||
// Silently fail during cleanup
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// If anything fails, log but don't break the app
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('AnalyticsProvider initialization error:', error);
|
|
||||||
}
|
|
||||||
// Return empty cleanup function
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}, [trackPageView]);
|
|
||||||
|
|
||||||
// Always render children, even if analytics fails
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
144
lib/analytics.ts
144
lib/analytics.ts
@@ -1,144 +0,0 @@
|
|||||||
// Analytics utilities for Umami with Performance Tracking
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
umami?: {
|
|
||||||
track: (event: string, data?: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PerformanceMetric {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
url: string;
|
|
||||||
timestamp: number;
|
|
||||||
userAgent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebVitalsMetric {
|
|
||||||
name: 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB';
|
|
||||||
value: number;
|
|
||||||
delta: number;
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track custom events to Umami
|
|
||||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
const trackFn = window.umami?.track;
|
|
||||||
if (typeof trackFn !== "function") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
trackFn(event, {
|
|
||||||
...data,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - analytics must never break the app
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
console.warn("Error tracking Umami event:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track performance metrics
|
|
||||||
export const trackPerformance = (metric: PerformanceMetric) => {
|
|
||||||
trackEvent('performance', {
|
|
||||||
metric: metric.name,
|
|
||||||
value: Math.round(metric.value),
|
|
||||||
url: metric.url,
|
|
||||||
userAgent: metric.userAgent,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track Web Vitals
|
|
||||||
export const trackWebVitals = (metric: WebVitalsMetric) => {
|
|
||||||
trackEvent('web-vitals', {
|
|
||||||
name: metric.name,
|
|
||||||
value: Math.round(metric.value),
|
|
||||||
delta: Math.round(metric.delta),
|
|
||||||
id: metric.id,
|
|
||||||
url: metric.url,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track page load performance
|
|
||||||
export const trackPageLoad = () => {
|
|
||||||
if (typeof window === 'undefined' || typeof performance === 'undefined') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const navigationEntries = performance.getEntriesByType('navigation');
|
|
||||||
const navigation = navigationEntries[0] as PerformanceNavigationTiming | undefined;
|
|
||||||
|
|
||||||
if (navigation && navigation.loadEventEnd && navigation.fetchStart) {
|
|
||||||
trackPerformance({
|
|
||||||
name: 'page-load',
|
|
||||||
value: navigation.loadEventEnd - navigation.fetchStart,
|
|
||||||
url: window.location.pathname,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track individual timing phases
|
|
||||||
trackEvent('page-timing', {
|
|
||||||
dns: navigation.domainLookupEnd && navigation.domainLookupStart
|
|
||||||
? Math.round(navigation.domainLookupEnd - navigation.domainLookupStart)
|
|
||||||
: 0,
|
|
||||||
tcp: navigation.connectEnd && navigation.connectStart
|
|
||||||
? Math.round(navigation.connectEnd - navigation.connectStart)
|
|
||||||
: 0,
|
|
||||||
request: navigation.responseStart && navigation.requestStart
|
|
||||||
? Math.round(navigation.responseStart - navigation.requestStart)
|
|
||||||
: 0,
|
|
||||||
response: navigation.responseEnd && navigation.responseStart
|
|
||||||
? Math.round(navigation.responseEnd - navigation.responseStart)
|
|
||||||
: 0,
|
|
||||||
dom: navigation.domContentLoadedEventEnd && navigation.responseEnd
|
|
||||||
? Math.round(navigation.domContentLoadedEventEnd - navigation.responseEnd)
|
|
||||||
: 0,
|
|
||||||
load: navigation.loadEventEnd && navigation.domContentLoadedEventEnd
|
|
||||||
? Math.round(navigation.loadEventEnd - navigation.domContentLoadedEventEnd)
|
|
||||||
: 0,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - performance tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Error tracking page load:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track API response times
|
|
||||||
export const trackApiCall = (endpoint: string, duration: number, status: number) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('api-call', {
|
|
||||||
endpoint,
|
|
||||||
duration: Math.round(duration),
|
|
||||||
status,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track user interactions
|
|
||||||
export const trackInteraction = (action: string, element?: string) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('interaction', {
|
|
||||||
action,
|
|
||||||
element,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track errors
|
|
||||||
export const trackError = (error: string, context?: string) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
trackEvent('error', {
|
|
||||||
error,
|
|
||||||
context,
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -196,9 +196,9 @@ if (typeof window === 'undefined') {
|
|||||||
}, 60000); // Clear every minute
|
}, 60000); // Clear every minute
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
|
export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const key = `admin_${ip}`;
|
const key = `${prefix}_${ip}`;
|
||||||
|
|
||||||
const current = rateLimitMap.get(key);
|
const current = rateLimitMap.get(key);
|
||||||
|
|
||||||
@@ -215,8 +215,8 @@ export function checkRateLimit(ip: string, maxRequests: number = 10, windowMs: n
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000): Record<string, string> {
|
export function getRateLimitHeaders(ip: string, maxRequests: number = 10, windowMs: number = 60000, prefix: string = 'admin'): Record<string, string> {
|
||||||
const current = rateLimitMap.get(`admin_${ip}`);
|
const current = rateLimitMap.get(`${prefix}_${ip}`);
|
||||||
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
const remaining = current ? Math.max(0, maxRequests - current.count) : maxRequests;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { trackWebVitals, trackPerformance } from './analytics';
|
|
||||||
|
|
||||||
// Web Vitals types
|
|
||||||
interface Metric {
|
|
||||||
name: string;
|
|
||||||
value: number;
|
|
||||||
delta: number;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple Web Vitals implementation (since we don't want to add external dependencies)
|
|
||||||
const getCLS = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
let clsValue = 0;
|
|
||||||
let sessionValue = 0;
|
|
||||||
let sessionEntries: PerformanceEntry[] = [];
|
|
||||||
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
if (!(entry as PerformanceEntry & { hadRecentInput?: boolean }).hadRecentInput) {
|
|
||||||
const firstSessionEntry = sessionEntries[0];
|
|
||||||
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
|
|
||||||
|
|
||||||
if (sessionValue && entry.startTime - lastSessionEntry.startTime < 1000 && entry.startTime - firstSessionEntry.startTime < 5000) {
|
|
||||||
sessionValue += (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
||||||
sessionEntries.push(entry);
|
|
||||||
} else {
|
|
||||||
sessionValue = (entry as PerformanceEntry & { value?: number }).value || 0;
|
|
||||||
sessionEntries = [entry];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionValue > clsValue) {
|
|
||||||
clsValue = sessionValue;
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'CLS',
|
|
||||||
value: clsValue,
|
|
||||||
delta: clsValue,
|
|
||||||
id: `cls-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail - CLS tracking is not critical
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('CLS tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'layout-shift', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('CLS observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFID = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
const processingStart = (entry as PerformanceEntry & { processingStart?: number }).processingStart;
|
|
||||||
if (processingStart !== undefined) {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'FID',
|
|
||||||
value: processingStart - entry.startTime,
|
|
||||||
delta: processingStart - entry.startTime,
|
|
||||||
id: `fid-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FID tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'first-input', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FID observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
if (entry.name === 'first-contentful-paint') {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'FCP',
|
|
||||||
value: entry.startTime,
|
|
||||||
delta: entry.startTime,
|
|
||||||
id: `fcp-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FCP tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'paint', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('FCP observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLCP = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
const entries = list.getEntries();
|
|
||||||
const lastEntry = entries[entries.length - 1];
|
|
||||||
|
|
||||||
if (lastEntry) {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'LCP',
|
|
||||||
value: lastEntry.startTime,
|
|
||||||
delta: lastEntry.startTime,
|
|
||||||
id: `lcp-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('LCP tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'largest-contentful-paint', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('LCP observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTTFB = (onPerfEntry: (metric: Metric) => void) => {
|
|
||||||
if (typeof window === 'undefined' || typeof PerformanceObserver === 'undefined') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const observer = new PerformanceObserver((list) => {
|
|
||||||
try {
|
|
||||||
for (const entry of list.getEntries()) {
|
|
||||||
if (entry.entryType === 'navigation') {
|
|
||||||
const navEntry = entry as PerformanceNavigationTiming;
|
|
||||||
if (navEntry.responseStart && navEntry.fetchStart) {
|
|
||||||
onPerfEntry({
|
|
||||||
name: 'TTFB',
|
|
||||||
value: navEntry.responseStart - navEntry.fetchStart,
|
|
||||||
delta: navEntry.responseStart - navEntry.fetchStart,
|
|
||||||
id: `ttfb-${Date.now()}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('TTFB tracking error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe({ type: 'navigation', buffered: true });
|
|
||||||
return observer;
|
|
||||||
} catch (error) {
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('TTFB observer initialization failed:', error);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Custom hook for Web Vitals tracking
|
|
||||||
export const useWebVitals = () => {
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Wrap everything in try-catch to prevent errors from breaking the app
|
|
||||||
try {
|
|
||||||
const safeNow = () => {
|
|
||||||
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
||||||
return performance.now();
|
|
||||||
}
|
|
||||||
return Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store web vitals for batch sending
|
|
||||||
const webVitals: Record<string, number> = {};
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const projectMatch = path.match(/\/projects\/([^\/]+)/);
|
|
||||||
const projectId = projectMatch ? projectMatch[1] : null;
|
|
||||||
const observers: PerformanceObserver[] = [];
|
|
||||||
|
|
||||||
const sendWebVitals = async () => {
|
|
||||||
if (Object.keys(webVitals).length >= 3) { // Wait for at least FCP, LCP, CLS
|
|
||||||
try {
|
|
||||||
await fetch('/api/analytics/track', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
type: 'performance',
|
|
||||||
projectId: projectId,
|
|
||||||
page: path,
|
|
||||||
performance: {
|
|
||||||
fcp: webVitals.FCP || 0,
|
|
||||||
lcp: webVitals.LCP || 0,
|
|
||||||
cls: webVitals.CLS || 0,
|
|
||||||
fid: webVitals.FID || 0,
|
|
||||||
ttfb: webVitals.TTFB || 0,
|
|
||||||
loadTime: safeNow()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Silently fail
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.error('Error sending web vitals:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track Core Web Vitals
|
|
||||||
const clsObserver = getCLS((metric) => {
|
|
||||||
webVitals.CLS = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (clsObserver) observers.push(clsObserver);
|
|
||||||
|
|
||||||
const fidObserver = getFID((metric) => {
|
|
||||||
webVitals.FID = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (fidObserver) observers.push(fidObserver);
|
|
||||||
|
|
||||||
const fcpObserver = getFCP((metric) => {
|
|
||||||
webVitals.FCP = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (fcpObserver) observers.push(fcpObserver);
|
|
||||||
|
|
||||||
const lcpObserver = getLCP((metric) => {
|
|
||||||
webVitals.LCP = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (lcpObserver) observers.push(lcpObserver);
|
|
||||||
|
|
||||||
const ttfbObserver = getTTFB((metric) => {
|
|
||||||
webVitals.TTFB = metric.value;
|
|
||||||
trackWebVitals({
|
|
||||||
...metric,
|
|
||||||
name: metric.name as 'CLS' | 'FID' | 'FCP' | 'LCP' | 'TTFB',
|
|
||||||
url: window.location.pathname,
|
|
||||||
});
|
|
||||||
sendWebVitals();
|
|
||||||
});
|
|
||||||
if (ttfbObserver) observers.push(ttfbObserver);
|
|
||||||
|
|
||||||
// Track page load performance
|
|
||||||
const handleLoad = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
trackPerformance({
|
|
||||||
name: 'page-load-complete',
|
|
||||||
value: safeNow(),
|
|
||||||
url: window.location.pathname,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (document.readyState === 'complete') {
|
|
||||||
handleLoad();
|
|
||||||
} else {
|
|
||||||
window.addEventListener('load', handleLoad);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Cleanup all observers
|
|
||||||
observers.forEach(observer => {
|
|
||||||
try {
|
|
||||||
observer.disconnect();
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
window.removeEventListener('load', handleLoad);
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// If Web Vitals initialization fails, don't break the app
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.warn('Web Vitals initialization failed:', error);
|
|
||||||
}
|
|
||||||
// Return empty cleanup function
|
|
||||||
return () => {};
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user