ifixkart-storefront/components/CategorySidebar.tsx

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