ifixkart-storefront/components/ProductsMegaMenu.tsx

304 lines
9.9 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { Star, PackageX } from 'lucide-react';
import {
catalogService,
CategoryResponse,
ProductCardResponse,
} from '@/services/api/catalogService';
import { formatCurrency, getImageUrl, parseMoney } from '@/lib/utils';
import {
CmsMegaMenuPanel,
hasCmsMegaGroups,
} from '@/components/CmsMegaMenuPanel';
import type { MegaMenuData } from '@/services/api/storefrontService';
import { storefrontService } from '@/services/api/storefrontService';
type DynamicTab = {
id: string;
label: string;
slug?: string;
};
type MegaProduct = {
id: string;
name: string;
slug: string;
image: string;
price: number;
oldPrice?: number;
discount?: number;
rating: number;
categoryId?: string;
};
function mapCard(p: ProductCardResponse): MegaProduct {
const price = parseMoney(p.price);
const old =
p.compare_price != null && parseMoney(p.compare_price) > price
? parseMoney(p.compare_price)
: undefined;
const discount =
parseMoney(p.discount_percent) ||
(old ? Math.round(((old - price) / old) * 100) : undefined);
return {
id: p.product_id,
name: p.name,
slug: p.slug,
image: getImageUrl(p.thumbnail_url) || '',
price,
oldPrice: old,
discount: discount || undefined,
rating: Number(p.rating) || 4,
categoryId: p.category_id,
};
}
function MegaProductCard({ product }: { product: MegaProduct }) {
const rating = Math.round(product.rating || 4);
const [imgFailed, setImgFailed] = useState(false);
const showImage = Boolean(product.image) && !imgFailed;
return (
<Link
href={`/products/${product.slug}`}
className="flex flex-col px-3 py-5 md:px-4 text-left group/card h-full min-w-0 max-w-full overflow-hidden hover:bg-[#fafafa] transition-colors"
>
<div className="relative w-full h-[148px] shrink-0 flex items-center justify-center mb-3 overflow-hidden">
{product.discount ? (
<span className="absolute top-0 right-0 z-10 bg-primary text-white text-[10px] font-bold px-1.5 py-0.5 leading-none">
-{product.discount}%
</span>
) : null}
{showImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={product.image}
alt={product.name}
onError={() => setImgFailed(true)}
className="max-h-full max-w-[85%] object-contain group-hover/card:scale-[1.03] transition-transform duration-300"
/>
) : (
<div className="w-24 h-24 bg-gray-100 rounded-sm" aria-hidden />
)}
</div>
<h5
className="text-[13px] font-medium text-[#222] line-clamp-2 leading-snug h-[34px] mb-1.5 group-hover/card:text-primary transition-colors"
title={product.name}
>
{product.name}
</h5>
<div className="flex items-center gap-0.5 mb-2 shrink-0">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={12}
className={
i < rating
? 'fill-[#f5a623] text-[#f5a623]'
: 'fill-none text-gray-300'
}
/>
))}
</div>
<div className="flex items-center gap-2 flex-wrap min-h-[22px]">
{product.oldPrice != null && product.oldPrice > product.price ? (
<span className="text-[12px] text-gray-400 line-through">
{formatCurrency(product.oldPrice)}
</span>
) : null}
<span className="text-[14px] font-bold text-[#111]">
{formatCurrency(product.price)}
</span>
</div>
</Link>
);
}
export const ProductsMegaMenu: React.FC<{ cms?: MegaMenuData | null }> = ({
cms,
}) => {
const [activeTabId, setActiveTabId] = useState<string>('all');
const [allProducts, setAllProducts] = useState<MegaProduct[]>([]);
const [categories, setCategories] = useState<CategoryResponse[]>([]);
const [loading, setLoading] = useState(true);
const [remote, setRemote] = useState<MegaMenuData | null>(cms ?? null);
useEffect(() => {
if (hasCmsMegaGroups(cms)) {
setRemote(cms ?? null);
return;
}
let cancelled = false;
storefrontService.getMegaMenu().then((menus) => {
if (cancelled) return;
setRemote(menus.products || null);
});
return () => {
cancelled = true;
};
}, [cms]);
useEffect(() => {
if (hasCmsMegaGroups(remote) || hasCmsMegaGroups(cms)) return;
let cancelled = false;
const load = async () => {
setLoading(true);
try {
const [cats, productRes] = await Promise.all([
catalogService.getCategories(),
catalogService.getProductsPaginated({ limit: 48 }),
]);
if (cancelled) return;
const catList = cats || [];
setCategories(catList);
setAllProducts((productRes.products || []).map((p) => mapCard(p)));
const rootCats = catList.filter((c) => !c.parent_category_id);
if (rootCats.length > 0) {
setActiveTabId(rootCats[0].category_id);
} else if (catList.length > 0) {
setActiveTabId(catList[0].category_id);
}
} finally {
if (!cancelled) setLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [cms, remote]);
const tabs: DynamicTab[] = useMemo(() => {
const rootCats = categories.filter((c) => !c.parent_category_id);
const topCats = rootCats.length > 0 ? rootCats : categories;
const dynamic = topCats.slice(0, 6).map((c) => ({
id: c.category_id,
label: c.name,
slug: c.slug,
}));
return dynamic.length > 0
? dynamic
: [{ id: 'all', label: 'All Products' }];
}, [categories]);
const filteredProducts = useMemo(() => {
if (activeTabId === 'all') {
return allProducts.slice(0, 5);
}
const childIds = new Set(
categories
.filter((c) => c.parent_category_id === activeTabId)
.map((c) => c.category_id)
);
childIds.add(activeTabId);
const matches = allProducts.filter(
(p) => p.categoryId && childIds.has(p.categoryId)
);
return (matches.length > 0 ? matches : allProducts).slice(0, 5);
}, [allProducts, categories, activeTabId]);
if (hasCmsMegaGroups(remote)) {
return <CmsMegaMenuPanel menu={remote!} />;
}
const activeTabLabel =
tabs.find((t) => t.id === activeTabId)?.label || 'Products';
return (
<div className="w-full max-w-full box-border bg-white border border-gray-200 shadow-[0_16px_40px_rgba(0,0,0,0.12)] rounded-b-md overflow-hidden">
{/* Centered tabs — dynamic from database categories */}
<div className="flex items-center justify-center gap-0 pt-4 pb-3.5 border-b border-[#ebebeb] flex-wrap px-4">
{tabs.map((tab, idx) => (
<React.Fragment key={tab.id}>
{idx > 0 && (
<span
className="text-[#d0d0d0] mx-4 text-[13px] select-none"
aria-hidden
>
|
</span>
)}
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setActiveTabId(tab.id);
}}
className={`text-[14px] font-semibold px-0.5 pb-1 transition-colors cursor-pointer border-b-2 ${
activeTabId === tab.id
? 'text-primary border-primary'
: 'text-[#333] border-transparent hover:text-primary'
}`}
aria-pressed={activeTabId === tab.id}
>
{tab.label}
</button>
</React.Fragment>
))}
</div>
{loading ? (
<div className="grid grid-cols-5 divide-x divide-[#e8e8e8] min-h-[360px] w-full">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="p-5 animate-pulse min-w-0 overflow-hidden">
<div className="aspect-square max-h-[160px] bg-gray-100 mb-4 mx-auto" />
<div className="h-4 bg-gray-100 rounded w-4/5 mb-2" />
<div className="h-3 bg-gray-100 rounded w-1/2 mb-2" />
<div className="h-4 bg-gray-100 rounded w-1/3" />
</div>
))}
</div>
) : filteredProducts.length === 0 ? (
<div className="flex flex-col items-center justify-center min-h-[280px] text-center px-6 py-10">
<PackageX className="w-10 h-10 text-gray-300 mb-2 stroke-[1.5]" />
<p className="text-sm font-semibold text-gray-700">
No products in {activeTabLabel}
</p>
<Link
href="/shop"
className="mt-4 text-[13px] font-bold text-primary hover:underline"
>
View all products
</Link>
</div>
) : (
<div className="grid grid-cols-5 divide-x divide-[#e8e8e8] w-full items-stretch">
{Array.from({ length: 5 }).map((_, i) => {
const product = filteredProducts[i];
if (!product) {
return <div key={`empty-${i}`} className="min-w-0" />;
}
return (
<MegaProductCard
key={`${activeTabId}-${product.id}`}
product={product}
/>
);
})}
</div>
)}
</div>
);
};
export function isProductsNavLabel(label: string): boolean {
const n = label.toLowerCase().trim();
if (n === 'products' || n === 'product') return true;
if (n.includes('featured product')) return true;
return n === 'hot products' || n === 'new products';
}