Files
novalon-website/src/components/layout/header.tsx
T
张翔 f08874f5c4 feat(ui): optimize CTA buttons with contextual copy and fix static export build issues
CTA Optimization:

- Implement scenario-based CTA text across 6 key locations

- Add responsive Header CTA with icon+compact design

- Enhance CTA Section with vision-driven copy

Bug Fixes:

- Fix useSearchParams() build failure with dynamic import wrapper

- Remove useSearchParams() from PageTransition component

- Fix React 19 useEffect lint errors via useSyncExternalStore

UI Enhancements:

- Add ripple effects and gradient animations to Button

- Enhance loading skeleton with branded pulse animation
2026-05-11 19:03:37 +08:00

289 lines
11 KiB
TypeScript

'use client';
import { Suspense, useState, useEffect, useCallback } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { Menu, X, MessageCircle } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useTheme } from '@/contexts/theme-context';
import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA, type NavigationItemV2 } from '@/lib/constants';
import { MegaDropdown } from '@/components/layout/mega-dropdown';
import { useFocusTrap } from '@/hooks/use-focus-trap';
function HeaderContent() {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const pathname = usePathname();
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
const { resolvedTheme } = useTheme();
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('keydown', handleGlobalKeyDown);
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('keydown', handleGlobalKeyDown);
};
}, [isOpen]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsOpen(!isOpen);
}
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
}, [isOpen]);
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItemV2) => {
e.preventDefault();
window.location.href = item.href;
setIsOpen(false);
}, []);
const isActive = useCallback((item: NavigationItemV2) => {
if (item.id === 'contact') {
return pathname === '/contact';
}
if (item.id === 'home') {
return pathname === '/';
}
if (item.id === 'products') {
return pathname === '/products' || pathname.startsWith('/products/');
}
if (item.id === 'solutions') {
return pathname === '/solutions' || pathname.startsWith('/solutions/');
}
return pathname === `/${item.id}`;
}, [pathname]);
return (
<>
<header
className={`
fixed top-0 left-0 right-0 z-50
transition-all duration-300 ease-out
${isScrolled
? 'bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
: 'bg-transparent'
}
`}
>
<div className="container-wide">
<div className="flex items-center justify-between h-16">
<StaticLink
href="/"
className="flex items-center group"
aria-label="返回首页"
>
<Image
src={resolvedTheme === 'dark' ? '/logo-light.svg' : '/logo.svg'}
alt={COMPANY_INFO.name}
width={120}
height={30}
className="transition-transform duration-200 group-hover:scale-105 w-auto h-8 md:h-8"
loading="eager"
priority
/>
</StaticLink>
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
{NAVIGATION_V2.map((item) => (
item.hasDropdown ? (
<MegaDropdown
key={item.id}
label={item.label}
items={MEGA_DROPDOWN_DATA[item.dropdownKey!] ?? []}
isOpen={openDropdown === item.id}
onToggle={() => {
setOpenDropdown((prev) => prev === item.id ? null : item.id);
}}
onOpen={() => setOpenDropdown(item.id)}
onClose={() => setOpenDropdown((prev) => prev === item.id ? null : prev)}
/>
) : (
<StaticLink
key={item.id}
href={item.href}
onClick={(e) => handleNavClick(e, item)}
className={`
relative px-3 py-1.5 text-sm font-medium
transition-all duration-300
${isActive(item)
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}
`}
aria-current={isActive(item) ? 'page' : undefined}
>
{item.label}
<span
className={`
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[var(--color-brand-primary)] rounded-full
transition-all duration-200 ease-out
${isActive(item)
? 'opacity-100 scale-x-100'
: 'opacity-0 scale-x-0'
}
`}
/>
</StaticLink>
)
))}
</nav>
<div className="hidden md:flex items-center gap-3">
<ThemeToggle />
<Button
size="sm"
asChild
className="hidden lg:flex"
>
<StaticLink href="/contact" data-testid="consult-button">
<MessageCircle className="w-4 h-4 mr-1.5" />
</StaticLink>
</Button>
<Button
size="sm"
asChild
className="lg:hidden"
>
<StaticLink href="/contact" data-testid="consult-button"></StaticLink>
</Button>
</div>
<button
className="md:hidden p-3 -mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)] rounded-lg transition-all duration-200 active:scale-95"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
data-testid="mobile-menu-button"
style={{ minWidth: '44px', minHeight: '44px' }}
>
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
</div>
</header>
<AnimatePresence mode="wait">
{isOpen && (
<motion.div
ref={focusTrapRef}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 md:hidden"
>
<div
className="absolute inset-0 bg-black/30 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="absolute top-16 right-0 bottom-0 left-0 bg-[var(--color-bg-primary)]/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
id="mobile-menu"
role="navigation"
aria-label="移动端导航"
data-testid="mobile-navigation"
>
<nav className="container-wide py-6">
{NAVIGATION_V2.map((item, index) => (
<motion.div
key={item.id}
initial={{ x: 20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ delay: index * 0.05 }}
>
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
className={`
block px-4 py-4 text-base font-medium rounded-lg
transition-all duration-200
${isActive(item)
? 'text-[var(--color-text-primary)] bg-[var(--color-primary-lighter)] border-l-4 border-[var(--color-brand-primary)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
}
`}
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
>
{item.label}
</StaticLink>
</motion.div>
))}
<div className="mt-6 px-4 pt-6 border-t border-[var(--color-border-primary)]">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-[var(--color-text-muted)]"></span>
<ThemeToggle />
</div>
<Button
className="w-full"
asChild
size="lg"
>
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
<MessageCircle className="w-4 h-4 mr-2" />
</StaticLink>
</Button>
</div>
</nav>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
function HeaderFallback() {
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
<div className="container-wide">
<div className="flex items-center justify-between h-16">
<div className="h-8 w-8 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
<nav className="hidden md:flex items-center gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-6 w-16 bg-[var(--color-skeleton-bg)] animate-pulse rounded mx-1" />
))}
</nav>
<div className="h-9 w-20 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
</div>
</div>
</header>
);
}
export function Header() {
return (
<Suspense fallback={<HeaderFallback />}>
<HeaderContent />
</Suspense>
);
}