这是一个巴适伙伴企业官网项目
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

291 lines
14 KiB

import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { getLocalizedPath, getTranslation, type Locale } from '../i18n/utils';
import { Icon } from '../icons';
type SubnavItem = { title: string; desc: string; icon: string; href: string };
type Subnav = Record<string, SubnavItem>;
type Translation = ReturnType<typeof getTranslation>;
type NavKey = keyof Translation['nav'];
// 一个 nav 项可以是纯链接(无下拉)或带下拉(subnav 存在)。
// 下拉项的 href 在数据里:以 `#` 开头表示同页锚点(hash 跳转),否则视为绝对路径。
function resolveSubnavHref(pageKey: NavKey, anchor: string, locale: Locale): string {
return getLocalizedPath(`/${pageKey === 'home' ? '' : pageKey}`, locale) + (anchor.startsWith('#') ? anchor : `#${anchor}`);
}
export default function Header({ locale }: { locale: Locale }) {
const t = getTranslation(locale);
const brandName = t.brand || 'BaskBuddy';
const [menuOpen, setMenuOpen] = useState(false);
const [hoveredNav, setHoveredNav] = useState<NavKey | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const location = useLocation();
// 滚动时顶部自动收起:向下滚动隐藏,向上滚动显示(threshold 80px 避免抖动)。
const [hidden, setHidden] = useState(false);
useEffect(() => {
let lastY = window.scrollY;
const onScroll = () => {
const y = window.scrollY;
if (y > lastY && y > 80) {
setHidden(true);
} else if (y < lastY) {
setHidden(false);
}
lastY = y;
};
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
// 路由变化时主动关闭下拉 + 移动端菜单。
useEffect(() => {
setHoveredNav(null);
setMenuOpen(false);
}, [location.pathname]);
// Esc 关闭下拉。
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setHoveredNav(null);
setMenuOpen(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const isActive = (key: string) => {
const path = location.pathname.replace(/\/$/, '') || '/';
const target = getLocalizedPath(`/${key === 'home' ? '' : key}`, locale);
const normalizedTarget = target.replace(/\/$/, '') || '/';
return path === normalizedTarget;
};
// 悬停下拉的进出延迟:进入立即,移出 120ms 关闭(防误触,给鼠标留出走向下拉的窗口)。
const openDropdown = (key: NavKey) => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
setHoveredNav(key);
};
const scheduleClose = () => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => setHoveredNav(null), 120);
};
// 语言切换:保持当前页面,仅切换语言前缀。
const segments = location.pathname.split('/');
if (segments[1] === 'en' || segments[1] === 'hk') {
segments.splice(1, 1);
}
const basePath = segments.join('/') || '/';
const langHref = (target: Locale) => getLocalizedPath(basePath, target);
const locales: { code: Locale; label: string }[] = [
{ code: 'zh', label: '简' },
{ code: 'en', label: 'EN' },
{ code: 'hk', label: '繁' },
];
// py-1.5:触控目标提升至 ~28px,安全过 WCAG 2.5.8 AA(24px)下限;text-body 走 --fs-body 令牌(16px)。
const navBase = 'inline-flex items-center py-1.5 px-2 md:px-2.5 lg:px-3.5 text-body font-semibold transition-all duration-200';
const navIdle = 'text-[var(--brand-black)] hover:text-[var(--brand-accent-deep)] hover:underline underline-offset-4';
const navActive = 'text-[var(--brand-accent-deep)] font-bold underline underline-offset-4';
const navHasDropdown = 'gap-1';
const mobileNavBase = 'inline-flex items-center text-body py-2.5 px-3 transition-all duration-200 font-semibold';
const mobileNavIdle = 'text-[var(--brand-black)] hover:text-[var(--brand-accent-deep)] hover:underline underline-offset-4';
const mobileNavActive = 'text-[var(--brand-accent-deep)] font-bold underline underline-offset-4';
// 读取 subnav 数据:home/investors/news 没有 subnav 字段,返回 null。
const getSubnav = (key: NavKey): Subnav | null => {
const section = t[key] as { subnav?: Subnav } | undefined;
return section?.subnav ?? null;
};
return (
<header className={`sticky top-0 z-50 m-0 transition-all duration-300 bg-white border-b border-[var(--border-light)] shadow-[0_4px_24px_rgba(26,26,26,0.06)] ${hidden ? '-translate-y-full' : 'translate-y-0'}`}>
<nav className="w-full px-6 md:px-[44px] py-5 md:py-8 flex items-center justify-between gap-6">
{/* Brand Logo */}
<a
href={getLocalizedPath('/', locale)}
className="flex items-center group shrink-0"
onClick={() => { setMenuOpen(false); setHoveredNav(null); }}
>
<div className="flex items-baseline gap-1.5 leading-tight italic text-[var(--brand-accent)]">
<span
className="font-extrabold text-[27px] tracking-tight transition-colors duration-200"
style={{ fontFamily: 'var(--font-body)' }}
>
{brandName}
</span>
<span className="text-[27px] font-extrabold">
{t.brandSub || '巴适伙伴'}
</span>
</div>
</a>
{/* Right: Navigation + Language Switch + Mobile Menu */}
<div className="flex items-center justify-end gap-4 sm:gap-6">
{/* Desktop Navigation */}
<div className="hidden md:flex items-center gap-0.5">
{Object.entries(t.nav).map(([key, label]) => {
const navKey = key as NavKey;
const subnav = getSubnav(navKey);
const active = isActive(key);
if (!subnav) {
return (
<a
key={key}
href={getLocalizedPath(`/${navKey === 'home' ? '' : navKey}`, locale)}
className={`${navBase} ${active ? navActive : navIdle}`}
aria-current={active ? 'page' : undefined}
>
{label}
</a>
);
}
// 带下拉的导航项
const isOpen = hoveredNav === navKey;
return (
<div
key={key}
className="relative"
onMouseEnter={() => openDropdown(navKey)}
onMouseLeave={scheduleClose}
>
<a
href={getLocalizedPath(`/${navKey === 'home' ? '' : navKey}`, locale)}
className={`${navBase} ${navHasDropdown} ${active ? navActive : navIdle}`}
aria-current={active ? 'page' : undefined}
aria-expanded={isOpen}
aria-haspopup="true"
>
{label}
<svg
className={`w-3.5 h-3.5 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 9l6 6 6-6" />
</svg>
</a>
{/* 下拉面板:sm 屏以下不显示,桌面 hover 触发 */}
<div
className={`absolute top-full left-1/2 -translate-x-1/2 pt-3 transition-all duration-200 ${isOpen ? 'opacity-100 visible translate-y-0' : 'opacity-0 invisible -translate-y-1 pointer-events-none'}`}
onMouseEnter={() => openDropdown(navKey)}
onMouseLeave={scheduleClose}
>
<div className="min-w-[280px] md:min-w-[320px] rounded-2xl bg-white border border-[var(--border-light)] shadow-[0_18px_48px_rgba(26,26,26,0.10)] p-2.5">
{Object.entries(subnav).map(([subKey, item]) => (
<a
key={subKey}
href={resolveSubnavHref(navKey, item.href, locale)}
className="group flex items-start gap-3 rounded-xl px-3.5 py-3 hover:bg-[var(--bg-primary)] transition-colors duration-150"
onClick={() => setHoveredNav(null)}
>
<span className="icon-tile w-10 h-10 shrink-0">
<Icon name={item.icon} className="w-5 h-5" />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<div className="flex items-center gap-1.5 text-body font-semibold text-[var(--brand-black)] group-hover:text-[var(--brand-accent-deep)] transition-colors duration-150">
{item.title}
<svg className="w-3.5 h-3.5 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
<div className="mt-0.5 text-body text-[var(--text-muted)] leading-snug line-clamp-2">{item.desc}</div>
</div>
</a>
))}
</div>
</div>
</div>
);
})}
</div>
{/* Language Switch */}
<div className="hidden md:flex items-center gap-1.5 text-body font-medium">
{locales.map((l, i) => (
<span key={l.code} className="flex items-center gap-1.5">
{i > 0 && <span className="text-[var(--text-muted)]/50">·</span>}
<a
href={langHref(l.code)}
aria-current={locale === l.code ? 'true' : undefined}
className={`transition-all duration-200 ${locale === l.code ? 'text-[var(--brand-accent)] font-bold underline underline-offset-4' : 'text-[var(--text-muted)] hover:text-[var(--brand-black)] hover:underline underline-offset-4'}`}
>
{l.label}
</a>
</span>
))}
</div>
{/* Mobile Menu Button */}
<button
type="button"
onClick={() => setMenuOpen((o) => !o)}
className="md:hidden p-2 rounded-lg transition-colors duration-200 text-[var(--text-muted)] hover:bg-[var(--brand-cream-light)]"
aria-label={menuOpen ? (locale === 'en' ? 'Close menu' : '关闭菜单') : (locale === 'en' ? 'Open menu' : '打开菜单')}
aria-expanded={menuOpen}
>
{menuOpen ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</nav>
{/* Mobile Navigation */}
<div className={`md:hidden ${menuOpen ? 'block' : 'hidden'}`}>
<div className="px-4 pb-3 pt-1 bg-[rgba(255,255,255,0.82)] backdrop-blur-xl">
<div className="flex flex-col gap-0.5">
{Object.entries(t.nav).map(([key, label]) => {
const subnav = getSubnav(key as NavKey);
return (
<a
key={key}
href={getLocalizedPath(`/${key === 'home' ? '' : key}`, locale)}
onClick={() => { setMenuOpen(false); setHoveredNav(null); }}
className={`${mobileNavBase} ${isActive(key) ? mobileNavActive : mobileNavIdle}`}
aria-current={isActive(key) ? 'page' : undefined}
>
{label}
{subnav && (
<span className="ml-1 text-[10px] text-[var(--text-muted)]">▼</span>
)}
</a>
);
})}
</div>
<div className="flex items-center gap-1.5 text-body font-medium pt-2 mt-1 border-t border-[var(--border-light)]">
{locales.map((l, i) => (
<span key={l.code} className="flex items-center gap-1.5">
{i > 0 && <span className="text-[var(--text-muted)]/50">·</span>}
<a
href={langHref(l.code)}
onClick={() => { setMenuOpen(false); setHoveredNav(null); }}
aria-current={locale === l.code ? 'true' : undefined}
className={`transition-colors duration-200 ${locale === l.code ? 'text-[var(--brand-accent)]' : 'text-[var(--text-muted)] hover:text-[var(--brand-black)]'}`}
>
{l.label}
</a>
</span>
))}
</div>
</div>
</div>
</header>
);
}