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
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>
|
|
);
|
|
}
|
|
|