Files
novalon-website/src/components/analytics/CookieConsent.tsx
T
张翔 95f246fa36 feat(analytics): 增强 Google Analytics 隐私合规与追踪功能
- 修复 allow_google_signals 配置为 false,禁用跨设备追踪
- 升级 Cookie 同意组件,支持三级偏好控制(必要/分析/营销)
- 新增滚动深度追踪组件,追踪 25%/50%/75%/100% 里程碑
- 更新隐私政策,新增 Cookie 和网站分析工具章节
- 新增细化同意管理函数,支持 PIPL 合规
2026-04-22 22:09:38 +08:00

255 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
updateConsentDetailed,
trackButtonClick,
CookiePreferences,
getStoredPreferences,
storePreferences,
getDefaultPreferences,
} from '@/lib/analytics';
import { motion, AnimatePresence } from 'framer-motion';
const LEGACY_CONSENT_KEY = 'ga_consent';
export function CookieConsent() {
const [showConsent, setShowConsent] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>(getDefaultPreferences());
useEffect(() => {
const stored = getStoredPreferences();
if (stored) {
updateConsentDetailed(stored);
} else {
const legacyConsent = localStorage.getItem(LEGACY_CONSENT_KEY);
if (legacyConsent) {
const migratedPrefs: CookiePreferences = {
necessary: true,
analytics: legacyConsent === 'granted',
marketing: false,
timestamp: Date.now(),
};
storePreferences(migratedPrefs);
updateConsentDetailed(migratedPrefs);
localStorage.removeItem(LEGACY_CONSENT_KEY);
} else {
const timer = setTimeout(() => {
setShowConsent(true);
}, 2000);
return () => clearTimeout(timer);
}
}
return undefined;
}, []);
const handleSavePreferences = useCallback((prefs: CookiePreferences) => {
setIsAnimating(true);
const finalPrefs = { ...prefs, timestamp: Date.now() };
storePreferences(finalPrefs);
updateConsentDetailed(finalPrefs);
trackButtonClick('save_cookie_preferences', 'consent_banner');
setTimeout(() => {
setShowConsent(false);
setShowSettings(false);
setIsAnimating(false);
}, 300);
}, []);
const handleAcceptAll = () => {
const allAccepted: CookiePreferences = {
necessary: true,
analytics: true,
marketing: false,
timestamp: Date.now(),
};
handleSavePreferences(allAccepted);
trackButtonClick('accept_all_cookies', 'consent_banner');
};
const handleRejectAll = () => {
const allRejected: CookiePreferences = {
necessary: true,
analytics: false,
marketing: false,
timestamp: Date.now(),
};
handleSavePreferences(allRejected);
trackButtonClick('reject_all_cookies', 'consent_banner');
};
const handleTogglePreference = (key: 'analytics' | 'marketing') => {
setPreferences((prev) => ({ ...prev, [key]: !prev[key] }));
};
const handleSaveCustom = () => {
handleSavePreferences(preferences);
};
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">
{!showSettings ? (
<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 flex-wrap gap-3 shrink-0">
<button
onClick={() => setShowSettings(true)}
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={handleRejectAll}
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={handleAcceptAll}
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 className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-[#1C1C1C]">Cookie </h3>
<button
onClick={() => setShowSettings(false)}
className="text-gray-500 hover:text-gray-700"
aria-label="关闭设置"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<input
type="checkbox"
checked
disabled
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-not-allowed"
aria-label="必要 Cookie"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<span className="text-xs px-2 py-0.5 bg-gray-200 text-gray-600 rounded"></span>
</div>
<p className="text-sm text-[#5C5C5C] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
<input
type="checkbox"
checked={preferences.analytics}
onChange={() => handleTogglePreference('analytics')}
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
aria-label="分析 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<p className="text-sm text-[#5C5C5C] mt-1">
访使
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg opacity-50">
<input
type="checkbox"
checked={preferences.marketing}
onChange={() => handleTogglePreference('marketing')}
className="mt-1 h-4 w-4 rounded border-gray-300 text-[#C41E3A] focus:ring-[#C41E3A] cursor-pointer"
aria-label="营销 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[#1C1C1C]"> Cookie</span>
<p className="text-sm text-[#5C5C5C] mt-1">
广使
</p>
</div>
</div>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setShowSettings(false)}
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={handleSaveCustom}
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>
);
}
export function CookieSettingsButton() {
const [isVisible] = useState(() => {
if (typeof window === 'undefined') {return false;}
return !!getStoredPreferences();
});
if (!isVisible) {return null;}
return (
<button
onClick={() => {
const event = new CustomEvent('open-cookie-settings');
window.dispatchEvent(event);
}}
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-gray-600 bg-white border border-gray-200 rounded-lg shadow-sm hover:bg-gray-50 transition-colors"
aria-label="Cookie 设置"
>
Cookie
</button>
);
}