Files
novalon-website/src/components/analytics/CookieConsent.tsx
T
张翔 8840c4398a feat: downgrade tech stack to stable versions and integrate GA4 error monitoring
- Downgrade Next.js 16→14.2, React 19→18.3, Tailwind 4→3.4
- Add comprehensive GA4 error monitoring system
- Create Jenkins CI/CD pipeline with quality gates
- Fix build issues: ESLint, SWC conflict, config format
- Add documentation for deployment and error tracking
2026-05-12 12:45:18 +08:00

286 lines
12 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, useRef } from 'react';
import {
updateConsentDetailed,
trackButtonClick,
CookiePreferences,
getStoredPreferences,
storePreferences,
getDefaultPreferences,
} from '@/lib/analytics';
import { motion, AnimatePresence } from 'framer-motion';
const LEGACY_CONSENT_KEY = 'ga_consent';
let hasConsentBeenHandled = false;
function getInitialShowConsent(): boolean {
if (typeof window === 'undefined') {return false;}
if (hasConsentBeenHandled) {return false;}
const stored = getStoredPreferences();
if (stored) {return false;}
const legacyConsent = localStorage.getItem(LEGACY_CONSENT_KEY);
if (legacyConsent) {return false;}
return false;
}
export function CookieConsent() {
const [showConsent, setShowConsent] = useState(getInitialShowConsent);
const [showSettings, setShowSettings] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const [preferences, setPreferences] = useState<CookiePreferences>(getDefaultPreferences());
const consentCheckedRef = useRef(false);
useEffect(() => {
if (consentCheckedRef.current) {return;}
consentCheckedRef.current = true;
const stored = getStoredPreferences();
if (stored) {
hasConsentBeenHandled = true;
updateConsentDetailed(stored);
} else {
const legacyConsent = localStorage.getItem(LEGACY_CONSENT_KEY);
if (legacyConsent) {
hasConsentBeenHandled = true;
const migratedPrefs: CookiePreferences = {
necessary: true,
analytics: legacyConsent === 'granted',
marketing: false,
functionality: true,
timestamp: Date.now(),
};
storePreferences(migratedPrefs);
updateConsentDetailed(migratedPrefs);
localStorage.removeItem(LEGACY_CONSENT_KEY);
} else {
const timer = setTimeout(() => {
setShowConsent(true);
}, 2000);
return () => clearTimeout(timer);
}
}
return undefined;
}, []);
useEffect(() => {
const handleOpenSettings = () => {
setShowSettings(true);
setShowConsent(true);
};
window.addEventListener('open-cookie-settings', handleOpenSettings);
return () => window.removeEventListener('open-cookie-settings', handleOpenSettings);
}, []);
const handleSavePreferences = useCallback((prefs: CookiePreferences) => {
setIsAnimating(true);
hasConsentBeenHandled = 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,
functionality: true,
timestamp: Date.now(),
};
handleSavePreferences(allAccepted);
trackButtonClick('accept_all_cookies', 'consent_banner');
};
const handleRejectAll = () => {
const allRejected: CookiePreferences = {
necessary: true,
analytics: false,
marketing: false,
functionality: true,
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-[var(--color-bg-primary)] border-t border-[var(--color-border-secondary)] 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-[var(--color-text-secondary)]">
使 Cookie
使{' '}
<a
href="/privacy"
className="text-[var(--color-brand-primary)] hover:text-[var(--color-brand-primary-hover)] 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-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleRejectAll}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleAcceptAll}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] 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-[var(--color-text-primary)]">Cookie </h3>
<button
onClick={() => setShowSettings(false)}
className="text-[var(--color-text-placeholder)] hover:text-[var(--color-text-primary)]"
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-[var(--color-primary-lighter)] rounded-lg">
<input
type="checkbox"
checked
disabled
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-not-allowed"
aria-label="必要 Cookie"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-[var(--color-text-primary)]"> Cookie</span>
<span className="text-xs px-2 py-0.5 bg-[var(--color-border-secondary)] text-[var(--color-text-muted)] rounded"></span>
</div>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
<input
type="checkbox"
checked={preferences.analytics}
onChange={() => handleTogglePreference('analytics')}
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
aria-label="分析 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[var(--color-text-primary)]"> Cookie</span>
<p className="text-sm text-[var(--color-text-muted)] mt-1">
访使
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg opacity-50">
<input
type="checkbox"
checked={preferences.marketing}
onChange={() => handleTogglePreference('marketing')}
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
aria-label="营销 Cookie"
/>
<div className="flex-1">
<span className="font-medium text-[var(--color-text-primary)]"> Cookie</span>
<p className="text-sm text-[var(--color-text-muted)] 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-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
>
</button>
<button
onClick={handleSaveCustom}
disabled={isAnimating}
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] 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-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-primary-lighter)] transition-colors"
aria-label="Cookie 设置"
>
Cookie
</button>
);
}