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:
@@ -15,7 +15,7 @@ interface BreadcrumbProps {
|
||||
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="flex items-center space-x-2 text-sm text-[#5C5C5C] py-4">
|
||||
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
|
||||
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
|
||||
<Home className="w-4 h-4" />
|
||||
</StaticLink>
|
||||
{items.map((item, index) => (
|
||||
|
||||
@@ -3,17 +3,27 @@ import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href, ...props }: any) => (
|
||||
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
MockLink.displayName = 'MockLink';
|
||||
return MockLink;
|
||||
});
|
||||
|
||||
jest.mock('next/image', () => {
|
||||
return ({ src, alt, width, height, className, ...props }: any) => (
|
||||
const MockImage = ({ src, alt, width, height, className, ...props }: {
|
||||
src: string;
|
||||
alt: string;
|
||||
width: number;
|
||||
height: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<img src={src} alt={alt} width={width} height={height} className={className} {...props} />
|
||||
);
|
||||
MockImage.displayName = 'MockImage';
|
||||
return MockImage;
|
||||
});
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
@@ -94,9 +104,9 @@ describe('Footer', () => {
|
||||
it('should render service links', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('软件开发')).toBeInTheDocument();
|
||||
expect(screen.getByText('云服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据分析')).toBeInTheDocument();
|
||||
expect(screen.getByText('信息安全')).toBeInTheDocument();
|
||||
expect(screen.getByText('技术咨询')).toBeInTheDocument();
|
||||
expect(screen.getByText('解决方案')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact details', () => {
|
||||
|
||||
@@ -16,7 +16,8 @@ export function Footer() {
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-12 w-auto transition-transform duration-200 hover:scale-105"
|
||||
loading="lazy"
|
||||
loading="eager"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#5C5C5C] text-sm leading-relaxed mb-6">
|
||||
|
||||
@@ -10,6 +10,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { COMPANY_INFO, NAVIGATION, type NavigationItem } from '@/lib/constants';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__isProgrammaticScroll?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
function HeaderContent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
@@ -34,9 +40,9 @@ function HeaderContent() {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
|
||||
if (pathname === '/' && !isScrollingRef.current) {
|
||||
if (pathname === '/' && !isScrollingRef.current && !window.__isProgrammaticScroll) {
|
||||
const scrollPosition = window.scrollY + 100;
|
||||
const sections = ['home', 'services', 'solutions', 'products', 'cases', 'about', 'news'];
|
||||
const sections = ['home', 'services', 'solutions', 'products', 'cases', 'about', 'team', 'news'];
|
||||
|
||||
for (const sectionId of sections) {
|
||||
const element = document.getElementById(sectionId);
|
||||
@@ -158,6 +164,7 @@ function HeaderContent() {
|
||||
<StaticLink
|
||||
href="/"
|
||||
className="flex items-center group"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
@@ -165,6 +172,7 @@ function HeaderContent() {
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-auto transition-transform duration-200 group-hover:scale-105"
|
||||
loading="eager"
|
||||
priority
|
||||
/>
|
||||
</StaticLink>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useLayoutEffect, useRef } from 'react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -11,18 +12,45 @@ const tabs = [
|
||||
{ id: 'services', label: '服务', href: '/#services', icon: Briefcase },
|
||||
{ id: 'products', label: '产品', href: '/#products', icon: Package },
|
||||
{ id: 'news', label: '新闻', href: '/#news', icon: FileText },
|
||||
{ id: 'contact', label: '联系', href: '/#contact', icon: User },
|
||||
{ id: 'contact', label: '联系', href: '/contact', icon: User },
|
||||
];
|
||||
|
||||
export function MobileTabBar() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [hash, setHash] = useState('');
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') {
|
||||
return pathname === '/';
|
||||
useLayoutEffect(() => {
|
||||
if (!isInitializedRef.current) {
|
||||
isInitializedRef.current = true;
|
||||
setHash(window.location.hash.slice(1));
|
||||
}
|
||||
const basePath = href.split('#')[0] || href;
|
||||
return pathname.startsWith(basePath);
|
||||
|
||||
const handleHashChange = () => {
|
||||
setHash(window.location.hash.slice(1));
|
||||
};
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const isActive = (_href: string, id: string) => {
|
||||
if (id === 'contact') {
|
||||
return pathname === '/contact';
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
const section = searchParams.get('section');
|
||||
const currentSection = section || hash;
|
||||
|
||||
if (id === 'home') {
|
||||
return !currentSection || currentSection === 'home';
|
||||
}
|
||||
return currentSection === id;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -30,7 +58,7 @@ export function MobileTabBar() {
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = isActive(tab.href);
|
||||
const active = isActive(tab.href, tab.id);
|
||||
|
||||
return (
|
||||
<StaticLink
|
||||
|
||||
Reference in New Issue
Block a user