ifixkart-storefront/components/AllCategoriesDrawer.tsx

395 lines
15 KiB
TypeScript

'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import Link from 'next/link';
import { AnimatePresence, motion } from 'framer-motion';
import { ChevronRight, ChevronLeft, CircleUserRound, X, Loader2 } from 'lucide-react';
import { catalogService, CategoryResponse, HierarchyBrandItem } from '@/services/api/catalogService';
import { useAuthStore } from '@/store/authStore';
export type AllMenuItem = {
id: string;
label: string;
href?: string;
children?: AllMenuItem[];
heading?: boolean;
loadBrandsFor?: string;
categorySlug?: string;
};
type Panel = {
title: string;
items: AllMenuItem[];
};
const PANEL_WIDTH = 365;
function sortCategories(list: CategoryResponse[]) {
return [...list].sort((a, b) => {
const orderA = parseInt(String(a.sort_order || '0'), 10);
const orderB = parseInt(String(b.sort_order || '0'), 10);
return orderA - orderB;
});
}
function categoryChildren(all: CategoryResponse[], parentId: string) {
return sortCategories(all.filter((c) => c.parent_category_id === parentId));
}
function categoryHref(slug: string, extra?: string) {
return extra ? `/shop?category=${slug}&${extra}` : `/shop?category=${slug}`;
}
function buildCategorySubmenu(cat: CategoryResponse, all: CategoryResponse[]): AllMenuItem[] {
const kids = categoryChildren(all, cat.category_id);
const items: AllMenuItem[] = [
{ id: `${cat.category_id}-all`, label: `See all ${cat.name}`, href: categoryHref(cat.slug) },
];
if (kids.length > 0) {
items.push({ id: `${cat.category_id}-sub-h`, label: 'Shop by subcategory', heading: true });
kids.forEach((kid) => {
const grandkids = categoryChildren(all, kid.category_id);
items.push({
id: kid.category_id,
label: kid.name,
href: categoryHref(kid.slug),
children: grandkids.length > 0 ? buildCategorySubmenu(kid, all) : undefined,
});
});
}
items.push(
{ id: `${cat.category_id}-shop-h`, label: 'More in this category', heading: true },
{ id: `${cat.category_id}-new`, label: 'New Releases', href: categoryHref(cat.slug, 'sort=newest') },
{ id: `${cat.category_id}-featured`, label: 'Featured Items', href: categoryHref(cat.slug, 'featured=true') },
{ id: `${cat.category_id}-deals`, label: 'Best Prices', href: categoryHref(cat.slug, 'sort=price_asc') },
);
return items;
}
function brandsToItems(categorySlug: string, brands: HierarchyBrandItem[]): AllMenuItem[] {
return brands.map((brand) => {
const seriesItems: AllMenuItem[] | undefined =
brand.series?.length > 0
? [
{
id: `${brand.brand_id}-all`,
label: `See all ${brand.name}`,
href: `/shop?category=${categorySlug}&brand=${brand.slug}`,
},
{ id: `${brand.brand_id}-series-h`, label: 'Series', heading: true },
...brand.series.map((series) => ({
id: series.series_id,
label: series.name,
href: `/shop?category=${categorySlug}&brand=${brand.slug}&series=${series.slug}`,
children:
series.models?.length > 0
? [
{
id: `${series.series_id}-all`,
label: `See all ${series.name}`,
href: `/shop?category=${categorySlug}&brand=${brand.slug}&series=${series.slug}`,
},
{ id: `${series.series_id}-models-h`, label: 'Models', heading: true },
...series.models.map((model) => ({
id: model.model_id,
label: model.name,
href: `/shop?category=${categorySlug}&brand=${brand.slug}&series=${series.slug}&model=${model.slug}`,
})),
]
: undefined,
})),
]
: undefined;
return {
id: brand.brand_id,
label: brand.name,
href: `/shop?category=${categorySlug}&brand=${brand.slug}`,
children: seriesItems,
};
});
}
function buildRootItems(categories: CategoryResponse[]): AllMenuItem[] {
const topLevel = sortCategories(
categories.filter((c) => !c.parent_category_id)
);
const shopCats = topLevel.length > 0 ? topLevel : sortCategories(categories);
return [
{ id: 'h-trending', label: 'Trending', heading: true },
{ id: 'bestsellers', label: 'Bestsellers', href: '/shop?sort=bestselling' },
{ id: 'new-releases', label: 'New Releases', href: '/shop?sort=newest' },
{ id: 'deals', label: 'Today\'s Deals', href: '/shop?tag=deals' },
{ id: 'h-shop', label: 'Shop by Category', heading: true },
...shopCats.map((cat) => ({
id: cat.category_id,
label: cat.name,
href: categoryHref(cat.slug),
children: buildCategorySubmenu(cat, categories),
loadBrandsFor: cat.is_parent_feature ? cat.category_id : undefined,
categorySlug: cat.slug,
})),
{ id: 'h-programs', label: 'Programs & Features', heading: true },
{ id: 'repairs', label: 'Device Repairs', href: '/repair-services' },
{ id: 'all-cats', label: 'All Categories', href: '/categories' },
{ id: 'h-help', label: 'Help & Settings', heading: true },
{ id: 'account', label: 'Your Account', href: '/account' },
{ id: 'contact', label: 'Customer Service', href: '/contact' },
];
}
const slideVariants = {
enter: (dir: number) => ({ x: dir >= 0 ? '100%' : '-100%' }),
center: { x: 0 },
exit: (dir: number) => ({ x: dir >= 0 ? '-40%' : '100%' }),
};
export function AllCategoriesDrawer() {
const [mounted, setMounted] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [stack, setStack] = useState<Panel[]>([]);
const [direction, setDirection] = useState(1);
const { isAuthenticated, customer } = useAuthStore();
useEffect(() => {
setMounted(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
}, []);
const open = useCallback(() => {
setStack([{ title: 'Menu', items: [] }]);
setDirection(1);
setIsOpen(true);
}, []);
useEffect(() => {
const handleOpen = () => open();
window.addEventListener('openAllCategoriesDrawer', handleOpen);
return () => window.removeEventListener('openAllCategoriesDrawer', handleOpen);
}, [open]);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
catalogService.getCategories().then((res) => {
if (cancelled) return;
const list = Array.isArray(res) ? res : [];
setStack([{ title: 'Menu', items: buildRootItems(list) }]);
setDirection(1);
});
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
cancelled = true;
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [isOpen, close]);
const current = stack[stack.length - 1];
const greeting = isAuthenticated
? customer?.first_name || 'Account'
: 'Login';
const accountHref = '/account';
const openChildPanel = async (item: AllMenuItem) => {
if (!item.children?.length && !item.loadBrandsFor) return;
setDirection(1);
setStack((prev) => [
...prev,
{
title: item.label,
items: item.children?.length ? item.children : [],
},
]);
if (!item.loadBrandsFor) return;
const hierarchy = await catalogService.getParentHierarchy(item.loadBrandsFor);
const slug = item.categorySlug || item.loadBrandsFor;
const brandItems = hierarchy?.brands?.length
? brandsToItems(slug, hierarchy.brands)
: [];
setStack((prev) => {
const next = [...prev];
const last = next[next.length - 1];
if (!last) return prev;
const withoutPlaceholder = last.items.filter((row) => !row.loadBrandsFor);
next[next.length - 1] = {
...last,
items: brandItems.length
? [...withoutPlaceholder, { id: `${item.id}-brands-h`, label: 'Shop by Brand', heading: true }, ...brandItems]
: withoutPlaceholder,
};
return next;
});
};
const goBack = () => {
if (stack.length <= 1) return;
setDirection(-1);
setStack((prev) => prev.slice(0, -1));
};
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[220] font-sans" role="dialog" aria-modal="true" aria-label="All categories menu">
<motion.button
type="button"
aria-label="Close menu overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={close}
className="absolute inset-0 bg-black/50 backdrop-blur-xs cursor-default"
/>
<motion.aside
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'tween', duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
className="absolute top-0 left-0 bottom-0 bg-white shadow-soft flex flex-col border-r border-gray-100"
style={{ width: PANEL_WIDTH, maxWidth: '92vw' }}
>
<Link
href={accountHref}
onClick={close}
className="flex items-center gap-3 bg-primary text-white px-5 py-4 shrink-0 hover:bg-[#1565C0] transition-colors"
>
<span className="w-9 h-9 rounded-full bg-white/15 flex items-center justify-center shrink-0">
<CircleUserRound className="w-5 h-5 text-white" strokeWidth={1.75} />
</span>
<div className="min-w-0 text-left">
<span className="block text-[11px] font-medium text-blue-100 leading-none mb-1">My Account</span>
<span className="block text-[16px] font-bold tracking-tight font-outfit truncate">{greeting}</span>
</div>
</Link>
<div className="relative flex-1 overflow-hidden bg-[#F8F9FB]">
<AnimatePresence initial={false} custom={direction}>
{current && (
<motion.div
key={`${stack.length}-${current.title}`}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{ type: 'tween', duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
className="absolute inset-0 overflow-y-auto bg-white"
>
{stack.length > 1 && (
<button
type="button"
onClick={goBack}
className="w-full flex items-center gap-2 px-5 py-3.5 text-[13px] font-bold text-primary border-b border-gray-100 hover:bg-primary-light cursor-pointer transition-colors"
>
<ChevronLeft className="w-4 h-4" />
Main Menu
</button>
)}
{stack.length > 1 && (
<div className="px-5 py-3 border-b border-gray-100 bg-[#F8F9FB]">
<h3 className="text-[15px] font-extrabold text-[#1E293B] leading-tight font-outfit">
{current.title}
</h3>
</div>
)}
{current.items.length === 0 && (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
Loading
</div>
)}
<nav>
{current.items.map((item) => {
if (item.heading) {
return (
<h4
key={item.id}
className="px-5 pt-5 pb-2 text-[12px] font-extrabold uppercase tracking-wider text-primary border-t border-gray-100 first:border-t-0 first:pt-4 font-outfit"
>
{item.label}
</h4>
);
}
const hasChildren = Boolean(item.children?.length || item.loadBrandsFor);
if (hasChildren) {
return (
<button
key={item.id}
type="button"
onClick={() => void openChildPanel(item)}
className="w-full flex items-center justify-between px-5 py-3 text-[13.5px] font-semibold text-gray-700 hover:bg-primary-light hover:text-primary cursor-pointer text-left transition-colors"
>
<span>{item.label}</span>
<ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />
</button>
);
}
if (!item.href) return null;
return (
<Link
key={item.id}
href={item.href}
onClick={close}
className="flex items-center justify-between px-5 py-3 text-[13.5px] font-semibold text-gray-700 hover:bg-primary-light hover:text-primary transition-colors"
>
<span>{item.label}</span>
</Link>
);
})}
</nav>
<div className="h-8" />
</motion.div>
)}
</AnimatePresence>
</div>
</motion.aside>
<motion.button
type="button"
onClick={close}
aria-label="Close All Categories menu"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.18, delay: 0.08 }}
className="absolute top-3 w-9 h-9 rounded-lg bg-white text-gray-600 hover:text-primary hover:bg-primary-light shadow-soft flex items-center justify-center cursor-pointer z-[221] transition-colors"
style={{ left: `min(${PANEL_WIDTH + 12}px, calc(92vw + 12px))` }}
>
<X className="w-5 h-5" strokeWidth={2.2} />
</motion.button>
</div>
)}
</AnimatePresence>,
document.body
);
}