304 lines
9.9 KiB
TypeScript
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';
|
|
}
|
|
|