395 lines
15 KiB
TypeScript
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
|
|
);
|
|
}
|