250 lines
8.4 KiB
TypeScript
250 lines
8.4 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import AccountMenu from "@/components/AccountMenu";
|
|
import { products, site } from "@/data/site";
|
|
import { localeOptions, showsCatalog } from "@/i18n/config";
|
|
import { useLocale, useMessages, useSetLocale } from "@/i18n/locale-context";
|
|
|
|
function SearchIcon() {
|
|
return (
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
<circle cx="11" cy="11" r="6.25" stroke="currentColor" strokeWidth="1.75" />
|
|
<path d="M16 16.5 20 20.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChevronIcon() {
|
|
return (
|
|
<svg className="nav-caret" width="10" height="10" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export default function Header() {
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const locale = useLocale();
|
|
const t = useMessages();
|
|
const setLocale = useSetLocale();
|
|
const catalog = showsCatalog(locale);
|
|
const [open, setOpen] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [productsOpen, setProductsOpen] = useState(false);
|
|
const [hash, setHash] = useState("");
|
|
const searchRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
function onPointer(event: MouseEvent) {
|
|
if (!searchRef.current?.contains(event.target as Node)) setSearchOpen(false);
|
|
}
|
|
function onKey(event: KeyboardEvent) {
|
|
if (event.key === "Escape") setSearchOpen(false);
|
|
}
|
|
document.addEventListener("mousedown", onPointer);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onPointer);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (searchOpen) inputRef.current?.focus();
|
|
}, [searchOpen]);
|
|
|
|
useEffect(() => {
|
|
function syncHash() {
|
|
setHash(window.location.hash);
|
|
}
|
|
syncHash();
|
|
window.addEventListener("hashchange", syncHash);
|
|
return () => window.removeEventListener("hashchange", syncHash);
|
|
}, [pathname]);
|
|
|
|
const results = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (q.length < 2) return [];
|
|
const pages = [
|
|
{ href: "/", title: t.nav.home },
|
|
...(catalog
|
|
? [
|
|
{ href: "/products", title: t.nav.products },
|
|
{ href: "/blog", title: t.nav.blog },
|
|
]
|
|
: []),
|
|
{ href: "/about", title: t.nav.about },
|
|
{ href: "/contact", title: t.nav.contact },
|
|
];
|
|
const catalogItems = catalog
|
|
? t.categories.map((product) => ({
|
|
href: `/#product-${product.id}`,
|
|
title: product.title,
|
|
}))
|
|
: [];
|
|
return [...pages, ...catalogItems].filter((item) => item.title.toLowerCase().includes(q)).slice(0, 8);
|
|
}, [catalog, query, t]);
|
|
|
|
function onSearch(event: FormEvent) {
|
|
event.preventDefault();
|
|
const first = results[0];
|
|
if (!first) return;
|
|
router.push(first.href);
|
|
setSearchOpen(false);
|
|
setQuery("");
|
|
}
|
|
|
|
return (
|
|
<header className="site-header">
|
|
<div className="container header-inner">
|
|
<div className="header-start">
|
|
<Link href="/" className="brand" onClick={() => setOpen(false)}>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={site.logo} alt={t.brand.shortName} width={32} height={32} />
|
|
<span className="brand-text">
|
|
<strong>{t.brand.shortName}</strong>
|
|
{locale !== "en" ? <span className="en">{t.brand.nameEnShort}</span> : null}
|
|
</span>
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
className="menu-toggle"
|
|
aria-expanded={open}
|
|
aria-label={t.nav.menu}
|
|
onClick={() => setOpen((value) => !value)}
|
|
>
|
|
{t.nav.menu}
|
|
</button>
|
|
|
|
<nav className={open ? "nav open" : "nav"}>
|
|
<Link
|
|
href="/"
|
|
className={pathname === "/" && !hash.startsWith("#product") && hash !== "#products" ? "active" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t.nav.home}
|
|
</Link>
|
|
|
|
{catalog ? (
|
|
<div
|
|
className={productsOpen ? "nav-dropdown open" : "nav-dropdown"}
|
|
onMouseEnter={() => setProductsOpen(true)}
|
|
onMouseLeave={() => setProductsOpen(false)}
|
|
>
|
|
<Link
|
|
href="/products"
|
|
className={pathname.startsWith("/products") || hash === "#products" || hash.startsWith("#product-") ? "active" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t.nav.products}
|
|
<ChevronIcon />
|
|
</Link>
|
|
<ul className="nav-sub">
|
|
{products.map((product) => {
|
|
const copy = t.categories.find((item) => item.id === product.id);
|
|
return (
|
|
<li key={product.id}>
|
|
<Link href={`/#product-${product.id}`} onClick={() => setOpen(false)}>
|
|
{copy?.title || product.title}
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
|
|
{catalog ? (
|
|
<Link
|
|
href="/blog"
|
|
className={pathname.startsWith("/blog") ? "active" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t.nav.blog}
|
|
</Link>
|
|
) : null}
|
|
|
|
<Link href="/about" className={pathname === "/about" ? "active" : undefined} onClick={() => setOpen(false)}>
|
|
{t.nav.about}
|
|
</Link>
|
|
<Link href="/contact" className={pathname === "/contact" ? "active" : undefined} onClick={() => setOpen(false)}>
|
|
{t.nav.contact}
|
|
</Link>
|
|
</nav>
|
|
</div>
|
|
|
|
<div className="header-tools">
|
|
<div className={searchOpen ? "header-search open" : "header-search"} ref={searchRef}>
|
|
<form onSubmit={onSearch}>
|
|
<label className="sr-only" htmlFor="site-search">
|
|
{t.nav.search}
|
|
</label>
|
|
<input
|
|
id="site-search"
|
|
ref={inputRef}
|
|
value={query}
|
|
tabIndex={searchOpen ? 0 : -1}
|
|
onChange={(event) => {
|
|
setQuery(event.target.value);
|
|
setSearchOpen(true);
|
|
}}
|
|
onFocus={() => setSearchOpen(true)}
|
|
placeholder={t.nav.search}
|
|
/>
|
|
<button
|
|
type={searchOpen && query.trim() ? "submit" : "button"}
|
|
className="header-tool"
|
|
aria-expanded={searchOpen}
|
|
aria-label={t.nav.search}
|
|
onClick={() => {
|
|
if (!searchOpen) setSearchOpen(true);
|
|
}}
|
|
>
|
|
<SearchIcon />
|
|
</button>
|
|
</form>
|
|
{searchOpen && results.length > 0 ? (
|
|
<ul className="header-search-results">
|
|
{results.map((item) => (
|
|
<li key={`${item.href}-${item.title}`}>
|
|
<Link
|
|
href={item.href}
|
|
onClick={() => {
|
|
setSearchOpen(false);
|
|
setQuery("");
|
|
}}
|
|
>
|
|
{item.title}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="header-lang">
|
|
<span className="sr-only">{t.nav.language}</span>
|
|
<select value={locale} onChange={(event) => setLocale(event.target.value)} aria-label={t.nav.languageSelect}>
|
|
{localeOptions.map((option) => (
|
|
<option key={option.id} value={option.id}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<AccountMenu />
|
|
</div>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|