Avoid calling undefined umami.track, add safe checks for Performance APIs, and clean up load listeners to prevent .call() crashes in Chrome.
298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { useWebVitals } from '@/lib/useWebVitals';
|
|
import { trackEvent, trackPageLoad } from '@/lib/analytics';
|
|
|
|
interface AnalyticsProviderProps {
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const AnalyticsProvider: React.FC<AnalyticsProviderProps> = ({ children }) => {
|
|
// Initialize Web Vitals tracking - wrapped to prevent crashes
|
|
// Hooks must be called unconditionally, but the hook itself handles errors
|
|
useWebVitals();
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
// Wrap entire effect in try-catch to prevent any errors from breaking the app
|
|
try {
|
|
|
|
// Track page view
|
|
const trackPageView = async () => {
|
|
const path = window.location.pathname;
|
|
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
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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
|
|
const trackPerformanceToAPI = async () => {
|
|
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
|
|
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);
|
|
}
|
|
}
|
|
}, 2000); // Wait 2 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);
|
|
}
|
|
|
|
// Track route changes (for SPA navigation)
|
|
const handleRouteChange = () => {
|
|
setTimeout(() => {
|
|
trackPageView();
|
|
trackPageLoad();
|
|
}, 100);
|
|
};
|
|
|
|
// Listen for popstate events (back/forward navigation)
|
|
window.addEventListener('popstate', handleRouteChange);
|
|
|
|
// Track user interactions
|
|
const handleClick = (event: MouseEvent) => {
|
|
try {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const target = event.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);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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
|
|
let maxScrollDepth = 0;
|
|
const firedScrollMilestones = new Set<number>();
|
|
const handleScroll = () => {
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
// 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 {
|
|
// Remove load handler if we added it
|
|
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 () => {};
|
|
}
|
|
}, []);
|
|
|
|
// Always render children, even if analytics fails
|
|
return <>{children}</>;
|
|
};
|