feat(analytics): enhance Google Analytics with privacy compliance and comprehensive tracking

- Add automatic route change tracking for SPA navigation
- Implement Cookie consent banner for GDPR compliance
- Add performance tracking (LCP, FID, CLS Web Vitals)
- Add outbound link click tracking
- Integrate contact form submission tracking with conversion events
- Add CTA button click tracking in hero section
- Integrate error tracking in ErrorBoundary component
- Extend analytics utility library with 15+ tracking functions
- Configure IP anonymization and privacy settings
- Remove unused test files and deployment scripts
- Update case studies to include only specified cases
- Fix mobile navigation active state issues
- Fix lint errors in test files and components

BREAKING CHANGE: Google Analytics now requires user consent before tracking
This commit is contained in:
张翔
2026-04-22 07:19:29 +08:00
parent b117372b03
commit 2f45818724
45 changed files with 652 additions and 2293 deletions
@@ -0,0 +1,95 @@
'use client';
import { useState, useEffect } from 'react';
import { updateConsent, trackButtonClick } from '@/lib/analytics';
import { motion, AnimatePresence } from 'framer-motion';
const CONSENT_KEY = 'ga_consent';
export function CookieConsent() {
const [showConsent, setShowConsent] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
useEffect(() => {
const consent = localStorage.getItem(CONSENT_KEY);
if (!consent) {
const timer = setTimeout(() => {
setShowConsent(true);
}, 2000);
return () => clearTimeout(timer);
} else if (consent === 'granted') {
updateConsent(true);
}
return undefined;
}, []);
const handleAccept = () => {
setIsAnimating(true);
localStorage.setItem(CONSENT_KEY, 'granted');
updateConsent(true);
trackButtonClick('accept_cookies', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setIsAnimating(false);
}, 300);
};
const handleDecline = () => {
setIsAnimating(true);
localStorage.setItem(CONSENT_KEY, 'denied');
updateConsent(false);
trackButtonClick('decline_cookies', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setIsAnimating(false);
}, 300);
};
return (
<AnimatePresence>
{showConsent && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-16 md:bottom-0 left-0 right-0 z-[9998] bg-white border-t border-gray-200 shadow-lg"
>
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex-1">
<p className="text-sm text-gray-700">
我们使用 Cookie 和类似技术来改善您的体验、分析网站流量并提供个性化内容。
继续使用即表示您同意我们的{' '}
<a
href="/privacy"
className="text-[#C41E3A] hover:text-[#A01830] underline font-medium"
>
隐私政策
</a>
。
</p>
</div>
<div className="flex gap-3 shrink-0">
<button
onClick={handleDecline}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
拒绝
</button>
<button
onClick={handleAccept}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[#C41E3A] rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
>
接受
</button>
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
+44 -5
View File
@@ -1,13 +1,30 @@
'use client';
import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, Suspense } from 'react';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
export function GoogleAnalytics() {
if (!GA_MEASUREMENT_ID) {
return null;
}
function GoogleAnalyticsContent() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (!GA_MEASUREMENT_ID || typeof window === 'undefined') {return;}
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
if (window.gtag) {
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: url,
page_title: document.title,
page_location: window.location.origin + url,
});
}
}, [pathname, searchParams]);
if (!GA_MEASUREMENT_ID) {return null;}
return (
<>
@@ -20,11 +37,33 @@ export function GoogleAnalytics() {
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
// 默认禁用存储,等待用户同意
gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'wait_for_update': 500
});
gtag('config', '${GA_MEASUREMENT_ID}', {
send_page_view: false
send_page_view: false,
anonymize_ip: true,
allow_google_signals: true,
allow_ad_personalization_signals: false,
cookie_flags: 'SameSite=None;Secure'
});
`}
</Script>
</>
);
}
export function GoogleAnalytics() {
if (!GA_MEASUREMENT_ID) {return null;}
return (
<Suspense fallback={null}>
<GoogleAnalyticsContent />
</Suspense>
);
}
@@ -0,0 +1,31 @@
'use client';
import { useEffect } from 'react';
import { trackOutboundLink } from '@/lib/analytics';
export function OutboundLinkTracker() {
useEffect(() => {
if (typeof window === 'undefined') {return;}
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
const link = target.closest('a');
if (link && link.href) {
try {
const url = new URL(link.href);
if (url.hostname !== window.location.hostname && url.protocol.startsWith('http')) {
trackOutboundLink(link.href);
}
} catch {
// Invalid URL
}
}
};
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
return null;
}
@@ -0,0 +1,73 @@
'use client';
import { useEffect } from 'react';
import { trackPerformance } from '@/lib/analytics';
export function PerformanceTracker() {
useEffect(() => {
if (typeof window === 'undefined') {return;}
const reportWebVitals = (): (() => void) | undefined => {
if ('PerformanceObserver' in window) {
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
if (lastEntry) {
trackPerformance('LCP', lastEntry.startTime);
}
});
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const firstEntry = entries[0];
if (firstEntry && 'processingStart' in firstEntry) {
const fidEntry = firstEntry as PerformanceEventTiming;
trackPerformance('FID', fidEntry.processingStart - fidEntry.startTime);
}
});
const clsObserver = new PerformanceObserver((list) => {
let clsValue = 0;
for (const entry of list.getEntries()) {
if ('value' in entry && !(entry as LayoutShift).hadRecentInput) {
clsValue += (entry as LayoutShift).value;
}
}
if (clsValue > 0) {
trackPerformance('CLS', clsValue * 1000);
}
});
try {
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
fidObserver.observe({ type: 'first-input', buffered: true });
clsObserver.observe({ type: 'layout-shift', buffered: true });
} catch {
// Observer not supported
}
return () => {
lcpObserver.disconnect();
fidObserver.disconnect();
clsObserver.disconnect();
};
}
return undefined;
};
const cleanup = reportWebVitals();
return cleanup;
}, []);
return null;
}
interface PerformanceEventTiming extends PerformanceEntry {
processingStart: number;
startTime: number;
}
interface LayoutShift extends PerformanceEntry {
value: number;
hadRecentInput: boolean;
}