251 lines
7.8 KiB
TypeScript
251 lines
7.8 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { ChevronDown } from 'lucide-react';
|
|
import { catalogService, CategoryResponse } from '@/services/api/catalogService';
|
|
|
|
export const SIDEBAR_WIDTH = 280;
|
|
|
|
type SubLink = { label: string; href: string };
|
|
|
|
type MegaGroup = {
|
|
title: string;
|
|
href: string;
|
|
links: SubLink[];
|
|
};
|
|
|
|
type MenuItem = {
|
|
id: string;
|
|
label: string;
|
|
href: string;
|
|
/** Multi-column flyout — only on Our Store / Smart Devices / Phones */
|
|
mega?: MegaGroup[];
|
|
chevron?: 'down';
|
|
};
|
|
|
|
function catHref(slug: string) {
|
|
return `/shop?category=${encodeURIComponent(slug)}`;
|
|
}
|
|
|
|
function buildMenuFromCatalog(rawCategories: CategoryResponse[]): MenuItem[] {
|
|
if (!rawCategories || rawCategories.length === 0) return [];
|
|
const categories = rawCategories.filter((c) => (c as any).is_active !== false);
|
|
|
|
const rootCategories = categories.filter((c) => !c.parent_category_id);
|
|
const topList = rootCategories.length > 0 ? rootCategories : categories;
|
|
|
|
return topList.map((cat) => {
|
|
const subCats = categories.filter((c) => c.parent_category_id === cat.category_id);
|
|
const subLinks: SubLink[] = subCats.map((sub) => ({
|
|
label: sub.name,
|
|
href: catHref(sub.slug),
|
|
}));
|
|
|
|
|
|
return {
|
|
id: cat.category_id,
|
|
label: cat.name,
|
|
href: catHref(cat.slug),
|
|
chevron: subLinks.length > 0 ? 'down' : undefined,
|
|
mega: subLinks.length > 0 ? [{ title: cat.name, href: catHref(cat.slug), links: subLinks }] : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
export const STATIC_MENU: MenuItem[] = [];
|
|
|
|
export function getCategoryMenuItems(categories: CategoryResponse[]) {
|
|
return buildMenuFromCatalog(categories);
|
|
}
|
|
|
|
export function menuItemCategoryParam(href: string): string {
|
|
const match = String(href || '').match(/[?&]category=([^&]+)/i);
|
|
return match ? decodeURIComponent(match[1]) : '';
|
|
}
|
|
|
|
function MegaPanel({ groups }: { groups: MegaGroup[] }) {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const [offsetY, setOffsetY] = useState(0);
|
|
const [maxHeight, setMaxHeight] = useState<number | undefined>(undefined);
|
|
|
|
// Pack into 3 columns (2 groups each when possible), like the reference
|
|
const columns: MegaGroup[][] = [[], [], []];
|
|
groups.forEach((g, i) => {
|
|
columns[i % 3].push(g);
|
|
});
|
|
|
|
useLayoutEffect(() => {
|
|
const el = panelRef.current;
|
|
if (!el) return;
|
|
|
|
const EDGE = 12;
|
|
const sync = () => {
|
|
// Reset so we measure from the natural top-aligned position
|
|
el.style.transform = 'translateY(0)';
|
|
el.style.maxHeight = '';
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
const vh = window.innerHeight;
|
|
const nextMax = Math.max(120, vh - EDGE * 2);
|
|
setMaxHeight(nextMax);
|
|
|
|
const height = Math.min(rect.height, nextMax);
|
|
let shift = 0;
|
|
const bottom = rect.top + height;
|
|
if (bottom > vh - EDGE) {
|
|
shift = vh - EDGE - bottom;
|
|
}
|
|
if (rect.top + shift < EDGE) {
|
|
shift = EDGE - rect.top;
|
|
}
|
|
setOffsetY(shift);
|
|
};
|
|
|
|
sync();
|
|
window.addEventListener('resize', sync);
|
|
return () => window.removeEventListener('resize', sync);
|
|
}, [groups]);
|
|
|
|
return (
|
|
<div
|
|
ref={panelRef}
|
|
className="absolute left-full top-0 z-[60] w-[640px] max-w-[calc(100vw-320px)] bg-white border border-[#eeeeee] shadow-[0_8px_28px_rgba(0,0,0,0.1)] overflow-y-auto"
|
|
role="menu"
|
|
style={{
|
|
transform: offsetY ? `translateY(${offsetY}px)` : undefined,
|
|
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
|
|
}}
|
|
>
|
|
{/* Keeps hover alive across the seam between the row and the flyout */}
|
|
<div className="absolute right-full top-0 bottom-0 w-3" aria-hidden />
|
|
<div className="grid grid-cols-3 gap-x-10 px-8 py-6">
|
|
{columns.map((col, colIdx) => (
|
|
<div key={colIdx} className="flex flex-col gap-7 min-w-0">
|
|
{col.map((group) => (
|
|
<div key={group.title}>
|
|
<Link
|
|
href={group.href}
|
|
className="block text-[14px] font-bold text-[#111] hover:text-primary transition-colors mb-2.5"
|
|
>
|
|
{group.title}
|
|
</Link>
|
|
<ul className="space-y-2">
|
|
{group.links.map((item) => (
|
|
<li key={`${group.title}-${item.label}`}>
|
|
<Link
|
|
href={item.href}
|
|
className="block text-[13px] text-[#666] hover:text-primary transition-colors leading-snug"
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CategorySidebarProps {
|
|
initialCategories?: CategoryResponse[];
|
|
className?: string;
|
|
/** Floating panel under the navbar All Categories button */
|
|
isDropdown?: boolean;
|
|
}
|
|
|
|
export const CategorySidebar: React.FC<CategorySidebarProps> = ({
|
|
initialCategories,
|
|
className = '',
|
|
isDropdown = false,
|
|
}) => {
|
|
const [categories, setCategories] = useState<CategoryResponse[]>(
|
|
initialCategories || []
|
|
);
|
|
const [openId, setOpenId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (initialCategories?.length) {
|
|
setCategories(initialCategories);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
catalogService
|
|
.getCategories()
|
|
.then((res) => {
|
|
if (!cancelled && res?.length) setCategories(res);
|
|
})
|
|
.catch(() => {});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [initialCategories]);
|
|
|
|
const menu = useMemo(
|
|
() =>
|
|
categories.length > 0 ? buildMenuFromCatalog(categories) : STATIC_MENU,
|
|
[categories]
|
|
);
|
|
|
|
return (
|
|
<aside
|
|
id={isDropdown ? undefined : 'home-category-sidebar'}
|
|
className={`w-[280px] shrink-0 bg-white border border-primary rounded-b-lg overflow-visible ${
|
|
isDropdown ? 'border-t shadow-lg block' : 'border-t-0 hidden xl:block shadow-[0_4px_14px_rgba(25,118,243,0.08)]'
|
|
} relative z-50 ${className}`}
|
|
aria-label="Shop categories"
|
|
onMouseLeave={() => setOpenId(null)}
|
|
>
|
|
<ul className="relative flex flex-col rounded-b-lg bg-white overflow-visible">
|
|
{menu.map((item, idx) => {
|
|
const hasMega = Boolean(item.mega?.length);
|
|
const isOpen = openId === item.id;
|
|
|
|
return (
|
|
<li
|
|
key={item.id}
|
|
className="relative"
|
|
onMouseEnter={() => setOpenId(hasMega ? item.id : null)}
|
|
>
|
|
<Link
|
|
href={item.href}
|
|
className={`flex items-center justify-between gap-2 px-5 py-[13px] text-[13px] font-medium transition-colors ${
|
|
idx < menu.length - 1 ? 'border-b border-[#ececec]' : ''
|
|
} ${
|
|
isOpen
|
|
? 'text-primary bg-[#f7f9fc]'
|
|
: 'text-[#333] hover:text-primary hover:bg-[#f7f9fc]'
|
|
}`}
|
|
>
|
|
<span>{item.label}</span>
|
|
{hasMega ? (
|
|
<ChevronDown
|
|
size={14}
|
|
className={`shrink-0 transition-transform ${
|
|
isOpen ? 'text-primary rotate-180' : 'text-gray-400'
|
|
}`}
|
|
/>
|
|
) : null}
|
|
</Link>
|
|
|
|
{isOpen && item.mega ? (
|
|
<>
|
|
{/* Row-height hit area so hover survives when the panel shifts up */}
|
|
<div
|
|
className="absolute left-full top-0 h-full w-[640px] max-w-[calc(100vw-320px)] z-[55]"
|
|
aria-hidden
|
|
/>
|
|
<MegaPanel groups={item.mega} />
|
|
</>
|
|
) : null}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</aside>
|
|
);
|
|
};
|