386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useLayoutEffect, useRef } from 'react';
|
|
import Link from 'next/link';
|
|
import { ChevronDown } from 'lucide-react';
|
|
import { usePathname } from 'next/navigation';
|
|
import {
|
|
TopDealsMegaMenu,
|
|
isTopDealsNavLabel,
|
|
} from '@/components/TopDealsMegaMenu';
|
|
import {
|
|
ProductsMegaMenu,
|
|
isProductsNavLabel,
|
|
} from '@/components/ProductsMegaMenu';
|
|
import { ShopMegaMenu, isShopNavLabel } from '@/components/ShopMegaMenu';
|
|
import { FaqMenu, isFaqNavLink } from '@/components/faq/FaqMenu';
|
|
import {
|
|
getCategoryMenuItems,
|
|
// CategorySidebar is kept in the file but the current mega sidebar is commented out below.
|
|
} from '@/components/CategorySidebar';
|
|
import { catalogService } from '@/services/api/catalogService';
|
|
import {
|
|
STOREFRONT_STICKY_NAV_GAP_PX,
|
|
STOREFRONT_STICKY_SCROLL_Y,
|
|
} from '@/layout/Header';
|
|
import { parseMetadata, resolveIsActive } from '@/lib/layoutConfig';
|
|
import { DEFAULT_NAV_LINKS } from '@/lib/homepageDefaults';
|
|
import {
|
|
storefrontService,
|
|
type AllMegaMenus,
|
|
} from '@/services/api/storefrontService';
|
|
|
|
interface NavLink {
|
|
label: string;
|
|
url: string;
|
|
hasDropdown: boolean;
|
|
}
|
|
|
|
function isContactNavLink(label: string, url?: string): boolean {
|
|
const text = String(label || '').toLowerCase();
|
|
const path = String(url || '').split('?')[0];
|
|
return text.includes('contact') || /^\/contact\/?$/i.test(path);
|
|
}
|
|
|
|
/** Contact Us and FAQs stay in the top announcement bar, not the main nav. */
|
|
function withoutUtilityNavLinks(links: NavLink[]): NavLink[] {
|
|
return links.filter(
|
|
(l) => !isContactNavLink(l.label, l.url) && !isFaqNavLink(l.label, l.url)
|
|
);
|
|
}
|
|
|
|
type MegaKind = 'deals' | 'products' | 'shop' | 'faq' | null;
|
|
|
|
/** Pick which mega panel to open when admin enables “dropdown on hover”. */
|
|
function resolveMegaKind(link: NavLink): MegaKind {
|
|
const url = link.url || '';
|
|
if (isFaqNavLink(link.label, url)) return 'faq';
|
|
if (!link.hasDropdown) return null;
|
|
|
|
if (isTopDealsNavLabel(link.label) || /[?&]tag=deals/i.test(url)) return 'deals';
|
|
if (isProductsNavLabel(link.label) || /[?&]featured=/i.test(url)) return 'products';
|
|
if (isShopNavLabel(link.label) || url.startsWith('/shop')) return 'shop';
|
|
|
|
// Checkbox label in admin: “Show categories dropdown on hover”
|
|
return 'shop';
|
|
}
|
|
|
|
function mapNavLinks(raw: unknown[]): NavLink[] {
|
|
return raw
|
|
.filter((l: any) => l && l.label && l.url)
|
|
.map((l: any) => ({
|
|
label: String(l.label).trim(),
|
|
url: String(l.url).trim() || '/',
|
|
// Strict: only admin “Show categories dropdown on hover”
|
|
hasDropdown: Boolean(l.hasDropdown),
|
|
}));
|
|
}
|
|
|
|
function linksFromConfig(config: any): NavLink[] | null {
|
|
const meta = parseMetadata(config);
|
|
const raw = meta.links;
|
|
if (Array.isArray(raw) && raw.length > 0) return mapNavLinks(raw);
|
|
return null;
|
|
}
|
|
|
|
interface NavbarProps {
|
|
config?: any;
|
|
/** When false, hide configurable nav links (All Categories chrome stays). */
|
|
isActive?: boolean;
|
|
}
|
|
|
|
export const Navbar: React.FC<NavbarProps> = ({
|
|
config,
|
|
isActive = true,
|
|
}) => {
|
|
const pathname = usePathname();
|
|
const [isSticky, setIsSticky] = useState(false);
|
|
const [headerHeight, setHeaderHeight] = useState(0);
|
|
const [navLinks, setNavLinks] = useState<NavLink[]>(() =>
|
|
mapNavLinks(DEFAULT_NAV_LINKS as unknown[])
|
|
);
|
|
const [megaOpen, setMegaOpen] = useState<MegaKind>(null);
|
|
const [categoriesOpen, setCategoriesOpen] = useState(false);
|
|
const [categoryMenu, setCategoryMenu] = useState(() => getCategoryMenuItems([]));
|
|
const [megaMenus, setMegaMenus] = useState<AllMegaMenus>({});
|
|
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const categoriesRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const openMega = (kind: MegaKind) => {
|
|
if (!kind) return;
|
|
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
setCategoriesOpen(false);
|
|
setMegaOpen(kind);
|
|
};
|
|
|
|
const scheduleCloseMega = () => {
|
|
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
closeTimer.current = setTimeout(() => setMegaOpen(null), 120);
|
|
};
|
|
|
|
const toggleCategories = () => {
|
|
setMegaOpen(null);
|
|
setCategoriesOpen((open) => !open);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
catalogService.getCategories().then((res) => {
|
|
if (!cancelled) setCategoryMenu(getCategoryMenuItems(res || []));
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
storefrontService.getMegaMenu().then((menus) => {
|
|
if (!cancelled) setMegaMenus(menus || {});
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const defaults = mapNavLinks(DEFAULT_NAV_LINKS as unknown[]);
|
|
|
|
// Section Hidden → keep a usable default nav (do not blank the bar)
|
|
if (!isActive) {
|
|
setNavLinks(defaults);
|
|
setMegaOpen(null);
|
|
return;
|
|
}
|
|
|
|
if (config) {
|
|
// Prefer CMS links whenever the section is Visible
|
|
const fromCms = linksFromConfig(config);
|
|
setNavLinks(fromCms ?? defaults);
|
|
return;
|
|
}
|
|
|
|
fetch('/api/v1/storefront/layout/home?region=nav_links', {
|
|
cache: 'no-store',
|
|
})
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((data: any) => {
|
|
const rows = Array.isArray(data) ? data : data?.items || [];
|
|
if (rows.length > 0) {
|
|
const section = rows[0];
|
|
if (resolveIsActive(section) === false) {
|
|
setNavLinks(defaults);
|
|
return;
|
|
}
|
|
const fromCms = linksFromConfig(section);
|
|
setNavLinks(fromCms ?? defaults);
|
|
return;
|
|
}
|
|
setNavLinks(defaults);
|
|
})
|
|
.catch(() => setNavLinks(defaults));
|
|
}, [config, isActive]);
|
|
|
|
useEffect(() => {
|
|
const syncSticky = () => {
|
|
const next = window.scrollY > STOREFRONT_STICKY_SCROLL_Y;
|
|
setIsSticky(next);
|
|
if (next) {
|
|
setMegaOpen(null);
|
|
setCategoriesOpen(false);
|
|
}
|
|
};
|
|
|
|
syncSticky();
|
|
window.addEventListener('scroll', syncSticky, { passive: true });
|
|
return () => {
|
|
window.removeEventListener('scroll', syncSticky);
|
|
if (closeTimer.current) clearTimeout(closeTimer.current);
|
|
};
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const measure = () => {
|
|
const header = document.querySelector<HTMLElement>(
|
|
'[data-storefront-header]'
|
|
);
|
|
setHeaderHeight(header?.offsetHeight || 0);
|
|
};
|
|
measure();
|
|
window.addEventListener('resize', measure, { passive: true });
|
|
return () => window.removeEventListener('resize', measure);
|
|
}, [isSticky]);
|
|
|
|
useEffect(() => {
|
|
setCategoriesOpen(false);
|
|
setMegaOpen(null);
|
|
}, [pathname]);
|
|
|
|
useEffect(() => {
|
|
if (!categoriesOpen) return;
|
|
|
|
const onPointerDown = (event: MouseEvent) => {
|
|
const root = categoriesRef.current;
|
|
if (root && !root.contains(event.target as Node)) {
|
|
setCategoriesOpen(false);
|
|
}
|
|
};
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape') setCategoriesOpen(false);
|
|
};
|
|
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
document.addEventListener('keydown', onKeyDown);
|
|
return () => {
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
};
|
|
}, [categoriesOpen]);
|
|
|
|
return (
|
|
<nav
|
|
data-storefront-nav
|
|
className={`bg-white hidden xl:block z-[100] ${
|
|
isSticky
|
|
? 'fixed left-0 right-0 shadow-md py-0 border-b border-gray-100'
|
|
: 'relative pt-0'
|
|
}`}
|
|
style={
|
|
isSticky
|
|
? { top: headerHeight, paddingTop: STOREFRONT_STICKY_NAV_GAP_PX }
|
|
: undefined
|
|
}
|
|
>
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px]">
|
|
<div
|
|
className="relative w-full min-w-0"
|
|
onMouseLeave={scheduleCloseMega}
|
|
>
|
|
<div className="flex items-stretch w-full min-w-0">
|
|
<div
|
|
ref={categoriesRef}
|
|
className="relative w-[280px] shrink-0 self-start z-[130]"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={toggleCategories}
|
|
aria-haspopup="true"
|
|
aria-expanded={categoriesOpen}
|
|
aria-label="All Categories menu"
|
|
className="w-full bg-primary text-white py-4 px-6 font-bold text-[13px] flex items-center gap-3 rounded-t-lg select-none cursor-pointer hover:bg-primary-hover transition-colors"
|
|
>
|
|
<span className="flex flex-col gap-1 w-4" aria-hidden="true">
|
|
<span className="h-[2px] bg-white w-full rounded" />
|
|
<span className="h-[2px] bg-white w-4/5 rounded" />
|
|
<span className="h-[2px] bg-white w-full rounded" />
|
|
</span>
|
|
All Categories
|
|
</button>
|
|
|
|
{categoriesOpen && (
|
|
<div className="absolute left-0 top-full z-[140] w-[280px] bg-white border border-gray-200 rounded-b-lg shadow-[0_12px_32px_rgba(15,23,42,0.12)] overflow-hidden">
|
|
{/*
|
|
Current mega CategorySidebar — kept, not deleted:
|
|
<CategorySidebar isDropdown />
|
|
*/}
|
|
<ul className="max-h-[min(70vh,420px)] overflow-y-auto py-1">
|
|
{categoryMenu.map((item, idx) => (
|
|
<li key={item.id}>
|
|
<Link
|
|
href={item.href}
|
|
onClick={() => setCategoriesOpen(false)}
|
|
className={`flex items-center px-5 py-[13px] text-[13px] font-medium text-[#333] hover:text-primary hover:bg-[#f7f9fc] ${
|
|
idx < categoryMenu.length - 1 ? 'border-b border-[#ececec]' : ''
|
|
}`}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0 ml-8 lg:ml-10">
|
|
<div className="flex items-center justify-between min-w-0 w-full">
|
|
<div className="flex items-center gap-6 xl:gap-8 min-w-0 flex-wrap">
|
|
{withoutUtilityNavLinks(navLinks).map((link, idx) => {
|
|
const pathOnly = link.url.split('?')[0] || '/';
|
|
const linkActive =
|
|
pathname === link.url ||
|
|
pathname === pathOnly ||
|
|
(pathOnly !== '/' && pathname.startsWith(pathOnly));
|
|
const megaKind = resolveMegaKind(link);
|
|
|
|
if (megaKind) {
|
|
const open = megaOpen === megaKind;
|
|
return (
|
|
<div
|
|
key={`${link.label}-${idx}`}
|
|
className="relative py-[14.5px] shrink-0"
|
|
onMouseEnter={() => openMega(megaKind)}
|
|
onFocus={() => openMega(megaKind)}
|
|
>
|
|
<Link
|
|
href={link.url}
|
|
className={`font-bold text-[14px] hover:text-primary transition-colors flex items-center gap-1 ${
|
|
linkActive || open
|
|
? 'text-primary'
|
|
: 'text-gray-700'
|
|
}`}
|
|
aria-expanded={open}
|
|
aria-haspopup="true"
|
|
>
|
|
{link.label}
|
|
<ChevronDown
|
|
size={14}
|
|
className={`transition-colors ${
|
|
open ? 'text-primary' : 'text-gray-400'
|
|
}`}
|
|
/>
|
|
</Link>
|
|
{megaKind === 'faq' && open ? (
|
|
<FaqMenu onNavigate={() => setMegaOpen(null)} />
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
key={`${link.label}-${idx}`}
|
|
href={link.url}
|
|
className={`font-bold text-[14px] hover:text-primary transition-colors py-[14.5px] shrink-0 ${
|
|
linkActive ? 'text-primary' : 'text-gray-700'
|
|
}`}
|
|
>
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={`absolute left-0 right-0 top-full z-[120] w-full max-w-full box-border transition-all duration-200 ${
|
|
megaOpen && megaOpen !== 'faq'
|
|
? 'opacity-100 visible pointer-events-auto'
|
|
: 'opacity-0 invisible pointer-events-none'
|
|
}`}
|
|
onMouseEnter={() => megaOpen && megaOpen !== 'faq' && openMega(megaOpen)}
|
|
>
|
|
{megaOpen === 'deals' && <TopDealsMegaMenu cms={megaMenus.deals} />}
|
|
{megaOpen === 'shop' && <ShopMegaMenu cms={megaMenus.shop} />}
|
|
{megaOpen === 'products' && (
|
|
<ProductsMegaMenu cms={megaMenus.products} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
);
|
|
};
|