239 lines
8.5 KiB
TypeScript
239 lines
8.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { Layers } from 'lucide-react';
|
|
import {
|
|
catalogService,
|
|
CategoryResponse,
|
|
ProductCardResponse,
|
|
} from '@/services/api/catalogService';
|
|
import { formatCurrency, getImageUrl } from '@/lib/utils';
|
|
import {
|
|
CmsMegaMenuPanel,
|
|
hasCmsMegaGroups,
|
|
} from '@/components/CmsMegaMenuPanel';
|
|
import type { MegaMenuData } from '@/services/api/storefrontService';
|
|
import {
|
|
getMegaMenuProductSlugs,
|
|
storefrontService,
|
|
} from '@/services/api/storefrontService';
|
|
|
|
type ShopByItem = { name: string; slug: string; image?: string };
|
|
type RatedItem = {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
image: string;
|
|
price: number;
|
|
oldPrice?: number;
|
|
};
|
|
|
|
function mapCategories(cats: CategoryResponse[]): ShopByItem[] {
|
|
return cats.map((c) => ({
|
|
name: c.name,
|
|
slug: c.slug,
|
|
image: c.image_url ? getImageUrl(c.image_url) : undefined,
|
|
}));
|
|
}
|
|
|
|
function mapProducts(products: ProductCardResponse[]): RatedItem[] {
|
|
return products.slice(0, 6).map((p) => ({
|
|
id: p.product_id,
|
|
name: p.name,
|
|
slug: p.slug,
|
|
image: getImageUrl(p.thumbnail_url) || '',
|
|
price: Number(p.price || 0),
|
|
oldPrice:
|
|
p.compare_price && Number(p.compare_price) > Number(p.price)
|
|
? Number(p.compare_price)
|
|
: undefined,
|
|
}));
|
|
}
|
|
|
|
export const TopDealsMegaMenu: React.FC<{ cms?: MegaMenuData | null }> = ({
|
|
cms,
|
|
}) => {
|
|
const [shopBy, setShopBy] = useState<ShopByItem[]>([]);
|
|
const [topRated, setTopRated] = useState<RatedItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [remote, setRemote] = useState<MegaMenuData | null>(cms ?? null);
|
|
|
|
useEffect(() => {
|
|
if (cms) {
|
|
setRemote(cms);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
storefrontService.getMegaMenu().then((menus) => {
|
|
if (cancelled) return;
|
|
setRemote(menus.deals || null);
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [cms]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const pinned = getMegaMenuProductSlugs(remote);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const cats = await catalogService.getCategories().catch(() => []);
|
|
if (cancelled) return;
|
|
if (cats?.length) {
|
|
setShopBy(mapCategories(cats).slice(0, 8));
|
|
} else {
|
|
setShopBy([]);
|
|
}
|
|
|
|
if (pinned.length) {
|
|
const cards = await catalogService.getProductsBySlugs(pinned.slice(0, 6));
|
|
if (!cancelled && cards.length) {
|
|
setTopRated(mapProducts(cards));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const productRes = await catalogService
|
|
.getProductsPaginated({ sort: 'popular', limit: 6 })
|
|
.catch(() => catalogService.getProductsPaginated({ limit: 6 }));
|
|
if (!cancelled && productRes?.products?.length) {
|
|
setTopRated(mapProducts(productRes.products));
|
|
} else if (!cancelled) {
|
|
setTopRated([]);
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
void load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [remote]);
|
|
|
|
if (hasCmsMegaGroups(remote)) {
|
|
return <CmsMegaMenuPanel menu={remote!} />;
|
|
}
|
|
|
|
return (
|
|
<div className="w-full max-w-full bg-white border border-t-0 border-gray-200 shadow-[0_16px_40px_rgba(0,0,0,0.12)] rounded-b-md overflow-hidden flex min-h-[320px]">
|
|
{/* Shop By — left half */}
|
|
<div className="w-1/2 min-w-0 bg-white p-5 lg:p-7 border-r border-gray-100 flex flex-col">
|
|
<h4 className="text-center text-[16px] font-bold text-[#222] mb-5">
|
|
Shop By
|
|
</h4>
|
|
{shopBy.length > 0 ? (
|
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5 max-w-[440px] mx-auto w-full">
|
|
{shopBy.map((item) => (
|
|
<Link
|
|
key={item.slug}
|
|
href={`/shop?category=${encodeURIComponent(item.slug)}`}
|
|
className="flex flex-col items-center gap-2 group/item text-center min-w-0"
|
|
>
|
|
<span className="w-[64px] h-[64px] lg:w-[72px] lg:h-[72px] rounded-full border border-gray-200 bg-white flex items-center justify-center overflow-hidden p-2 group-hover/item:border-primary/40 transition-colors shrink-0">
|
|
{item.image ? (
|
|
<img
|
|
src={item.image}
|
|
alt={item.name}
|
|
className="max-w-full max-h-full object-contain"
|
|
/>
|
|
) : (
|
|
<Layers className="w-6 h-6 text-gray-400 group-hover/item:text-primary transition-colors" />
|
|
)}
|
|
</span>
|
|
<span className="text-[11px] lg:text-[12px] font-medium text-[#333] group-hover/item:text-primary transition-colors line-clamp-2 leading-snug px-0.5 w-full">
|
|
{item.name}
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-sm text-gray-400">
|
|
No categories available
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Top Rated — right half */}
|
|
<div className="w-1/2 min-w-0 bg-[#F5F6F8] p-5 lg:p-7 flex flex-col">
|
|
<h4 className="text-center text-[16px] font-bold text-[#222] mb-5">
|
|
Top Rated
|
|
</h4>
|
|
{loading ? (
|
|
<div className="bg-white border border-gray-200 rounded-md overflow-hidden grid grid-cols-2 flex-1">
|
|
{Array.from({ length: 4 }).map((_, idx) => (
|
|
<div key={idx} className="p-4 animate-pulse flex items-center gap-3">
|
|
<div className="w-12 h-12 bg-gray-100 rounded" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-3 bg-gray-100 rounded w-3/4" />
|
|
<div className="h-3 bg-gray-100 rounded w-1/2" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : topRated.length > 0 ? (
|
|
<div className="bg-white border border-gray-200 rounded-md overflow-hidden grid grid-cols-2">
|
|
{topRated.map((product, idx) => (
|
|
<Link
|
|
key={product.id}
|
|
href={`/products/${product.slug}`}
|
|
className={`flex items-center gap-2.5 p-3 hover:bg-gray-50 transition-colors border-gray-100 min-w-0 ${
|
|
idx % 2 === 0 ? 'border-r' : ''
|
|
} ${idx < topRated.length - 2 ? 'border-b' : ''}`}
|
|
>
|
|
<span className="w-12 h-12 lg:w-14 lg:h-14 shrink-0 flex items-center justify-center overflow-hidden bg-white">
|
|
{product.image ? (
|
|
<img
|
|
src={product.image}
|
|
alt={product.name}
|
|
className="max-w-full max-h-full object-contain"
|
|
/>
|
|
) : (
|
|
<span className="w-full h-full bg-gray-100 rounded flex items-center justify-center text-[10px] text-gray-400">
|
|
No Img
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 text-left flex-1">
|
|
<span className="block text-[12px] font-semibold text-[#222] line-clamp-2 leading-snug">
|
|
{product.name}
|
|
</span>
|
|
<span className="mt-1 flex items-center gap-1.5 flex-wrap">
|
|
{product.oldPrice != null && (
|
|
<span className="text-[11px] text-gray-400 line-through">
|
|
{formatCurrency(product.oldPrice)}
|
|
</span>
|
|
)}
|
|
<span className="text-[13px] font-bold text-[#222]">
|
|
{formatCurrency(product.price)}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center text-sm text-gray-400">
|
|
No top rated products
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export function isTopDealsNavLabel(label: string): boolean {
|
|
const n = label.toLowerCase().replace(/\s+/g, '');
|
|
return (
|
|
n.includes('topdeal') ||
|
|
n === 'deals' ||
|
|
n.includes('top-deals') ||
|
|
label.toLowerCase().includes('top deals')
|
|
);
|
|
}
|
|
|