447 lines
17 KiB
TypeScript
447 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import Link from 'next/link';
|
|
import Image from 'next/image';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import {
|
|
catalogService,
|
|
CategoryResponse,
|
|
CategoryParentHierarchyResponse,
|
|
HierarchyBrandItem,
|
|
HierarchySeriesItem,
|
|
HierarchyModelItem,
|
|
ProductResponse,
|
|
} from '@/services/api/catalogService';
|
|
import { BrandSelector } from './BrandSelector';
|
|
import { SeriesSelector } from './SeriesSelector';
|
|
import { ModelSelector } from './ModelSelector';
|
|
import { StandardCatalog } from './StandardCatalog';
|
|
import { formatCurrency, getCatalogPriceInfo, getImageUrl } from '@/lib/utils';
|
|
import { ShoppingCart, Heart, ArrowLeftRight, Search, RotateCcw } from 'lucide-react';
|
|
import { useCartStore } from '@/store/cartStore';
|
|
import { mapProductCardToProductResponse } from '@/utils/productMapper';
|
|
import { addToCompareAndNavigate } from '@/lib/compare';
|
|
import BlurHashImage from '@/components/ui/BlurHashImage';
|
|
|
|
interface ParentFeatureCatalogProps {
|
|
category: CategoryResponse;
|
|
}
|
|
|
|
export function ParentFeatureCatalog({ category }: ParentFeatureCatalogProps) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
|
|
// Hierarchy Data state fetched once
|
|
const [hierarchy, setHierarchy] = useState<CategoryParentHierarchyResponse | null>(null);
|
|
const [isHierarchyLoading, setIsHierarchyLoading] = useState(true);
|
|
|
|
// Model search input state
|
|
const [modelSearchQuery, setModelSearchQuery] = useState('');
|
|
|
|
// Selection states derived from / synced with URL search params
|
|
const brandParam = searchParams.get('brand');
|
|
const seriesParam = searchParams.get('series');
|
|
const modelParam = searchParams.get('model');
|
|
|
|
// Filtered Products state
|
|
const [products, setProducts] = useState<ProductResponse[]>([]);
|
|
const [totalProducts, setTotalProducts] = useState<number>(0);
|
|
const [isProductsLoading, setIsProductsLoading] = useState(true);
|
|
|
|
const addToCart = useCartStore((state) => state.addToCart);
|
|
const toggleWishlist = useCartStore((state) => state.toggleWishlist);
|
|
const wishlist = useCartStore((state) => state.wishlist);
|
|
const compareList = useCartStore((state) => state.compareList);
|
|
|
|
// Fetch Parent Hierarchy Data ONCE for this category
|
|
useEffect(() => {
|
|
setIsHierarchyLoading(true);
|
|
catalogService
|
|
.getParentHierarchy(category.category_id)
|
|
.then((data) => {
|
|
setHierarchy(data);
|
|
})
|
|
.catch(() => {
|
|
setHierarchy(null);
|
|
})
|
|
.finally(() => {
|
|
setIsHierarchyLoading(false);
|
|
});
|
|
}, [category.category_id]);
|
|
|
|
// Derived Active Brands list
|
|
const brands: HierarchyBrandItem[] = useMemo(() => {
|
|
return hierarchy?.brands || [];
|
|
}, [hierarchy]);
|
|
|
|
// Find currently selected Brand object from URL param or ID/slug
|
|
const selectedBrandObj = useMemo(() => {
|
|
if (!brandParam || brands.length === 0) return null;
|
|
return (
|
|
brands.find(
|
|
(b) =>
|
|
b.brand_id === brandParam ||
|
|
b.slug.toLowerCase() === brandParam.toLowerCase()
|
|
) || null
|
|
);
|
|
}, [brands, brandParam]);
|
|
|
|
// Derived Series list for the selected Brand
|
|
const seriesList: HierarchySeriesItem[] = useMemo(() => {
|
|
return selectedBrandObj?.series || [];
|
|
}, [selectedBrandObj]);
|
|
|
|
// Find currently selected Series object
|
|
const selectedSeriesObj = useMemo(() => {
|
|
if (!seriesParam || seriesList.length === 0) return null;
|
|
return (
|
|
seriesList.find(
|
|
(s) =>
|
|
s.series_id === seriesParam ||
|
|
s.slug.toLowerCase() === seriesParam.toLowerCase()
|
|
) || null
|
|
);
|
|
}, [seriesList, seriesParam]);
|
|
|
|
// Derived Models list for selected Series or all Series under Brand
|
|
const rawModelsList: HierarchyModelItem[] = useMemo(() => {
|
|
if (selectedSeriesObj) {
|
|
return selectedSeriesObj.models;
|
|
}
|
|
if (selectedBrandObj) {
|
|
return selectedBrandObj.series.flatMap((s) => s.models);
|
|
}
|
|
return [];
|
|
}, [selectedBrandObj, selectedSeriesObj]);
|
|
|
|
// Filter models live by search query input
|
|
const modelsList: HierarchyModelItem[] = useMemo(() => {
|
|
if (!modelSearchQuery.trim()) return rawModelsList;
|
|
const q = modelSearchQuery.toLowerCase();
|
|
return rawModelsList.filter((m) => m.name.toLowerCase().includes(q));
|
|
}, [rawModelsList, modelSearchQuery]);
|
|
|
|
// Find currently selected Model object
|
|
const selectedModelObj = useMemo(() => {
|
|
if (!modelParam || rawModelsList.length === 0) return null;
|
|
return (
|
|
rawModelsList.find(
|
|
(m) =>
|
|
m.model_id === modelParam ||
|
|
m.slug.toLowerCase() === modelParam.toLowerCase()
|
|
) || null
|
|
);
|
|
}, [rawModelsList, modelParam]);
|
|
|
|
// URL State Updater Helper - Preserves category while updating selection
|
|
const updateUrlParams = useCallback(
|
|
(brandId: string | null, seriesId: string | null, modelId: string | null) => {
|
|
const params = new URLSearchParams();
|
|
params.set('category', category.slug || category.category_id);
|
|
|
|
if (brandId) params.set('brand', brandId);
|
|
if (seriesId) params.set('series', seriesId);
|
|
if (modelId) params.set('model', modelId);
|
|
|
|
router.push(`/products?${params.toString()}`, { scroll: false });
|
|
},
|
|
[category, router]
|
|
);
|
|
|
|
// Normalize invalid URL params safely
|
|
useEffect(() => {
|
|
if (isHierarchyLoading || !hierarchy) return;
|
|
|
|
let shouldNormalize = false;
|
|
let validBrand = brandParam;
|
|
let validSeries = seriesParam;
|
|
let validModel = modelParam;
|
|
|
|
if (brandParam && !selectedBrandObj) {
|
|
validBrand = null;
|
|
validSeries = null;
|
|
validModel = null;
|
|
shouldNormalize = true;
|
|
} else if (seriesParam && !selectedSeriesObj) {
|
|
validSeries = null;
|
|
validModel = null;
|
|
shouldNormalize = true;
|
|
} else if (modelParam && !selectedModelObj) {
|
|
validModel = null;
|
|
shouldNormalize = true;
|
|
}
|
|
|
|
if (shouldNormalize) {
|
|
updateUrlParams(validBrand, validSeries, validModel);
|
|
}
|
|
}, [
|
|
isHierarchyLoading,
|
|
hierarchy,
|
|
brandParam,
|
|
seriesParam,
|
|
modelParam,
|
|
selectedBrandObj,
|
|
selectedSeriesObj,
|
|
selectedModelObj,
|
|
updateUrlParams,
|
|
]);
|
|
|
|
// Handle Brand Selection
|
|
const handleSelectBrand = (brandId: string | null) => {
|
|
updateUrlParams(brandId, null, null);
|
|
};
|
|
|
|
// Handle Series Selection
|
|
const handleSelectSeries = (seriesId: string | null) => {
|
|
const brandId = selectedBrandObj?.brand_id || null;
|
|
updateUrlParams(brandId, seriesId, null);
|
|
};
|
|
|
|
// Handle Model Selection
|
|
const handleSelectModel = (modelId: string | null) => {
|
|
const brandId = selectedBrandObj?.brand_id || null;
|
|
const seriesId = selectedSeriesObj?.series_id || null;
|
|
updateUrlParams(brandId, seriesId, modelId);
|
|
};
|
|
|
|
// Fetch Products based on selected filters
|
|
useEffect(() => {
|
|
setIsProductsLoading(true);
|
|
const params: Record<string, any> = {
|
|
category: category.category_id,
|
|
page: 1,
|
|
limit: 36,
|
|
};
|
|
|
|
if (selectedBrandObj) {
|
|
params.brand = selectedBrandObj.brand_id;
|
|
}
|
|
if (selectedSeriesObj) {
|
|
params.series = selectedSeriesObj.series_id;
|
|
}
|
|
if (selectedModelObj) {
|
|
params.model = selectedModelObj.model_id;
|
|
}
|
|
|
|
catalogService
|
|
.getProductsPaginated(params)
|
|
.then((res) => {
|
|
const mapped: ProductResponse[] = res.products.map((p) =>
|
|
mapProductCardToProductResponse({
|
|
...p,
|
|
category_id: p.category_id || category.category_id,
|
|
brand_id: selectedBrandObj?.brand_id || '',
|
|
device_series_id: selectedSeriesObj?.series_id || '',
|
|
device_model_id: selectedModelObj?.model_id || '',
|
|
})
|
|
);
|
|
|
|
setProducts(mapped);
|
|
setTotalProducts(res.total);
|
|
})
|
|
.catch(() => {
|
|
setProducts([]);
|
|
setTotalProducts(0);
|
|
})
|
|
.finally(() => {
|
|
setIsProductsLoading(false);
|
|
});
|
|
}, [category.category_id, selectedBrandObj, selectedSeriesObj, selectedModelObj]);
|
|
|
|
// Fallback to StandardCatalog if hierarchy has no brands
|
|
if (!isHierarchyLoading && (!hierarchy || brands.length === 0)) {
|
|
return <StandardCatalog initialCategory={category} categoryParam={category.slug} />;
|
|
}
|
|
|
|
return (
|
|
<div className="w-full bg-white min-h-screen py-6 font-sans">
|
|
<div className="max-w-7xl mx-auto px-4">
|
|
{/* Top tools bar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8 pb-4 border-b border-gray-100">
|
|
{/* Top-left Brand Logo & Title */}
|
|
<div className="flex items-center gap-3">
|
|
<Link href="/" className="flex items-center select-none tracking-tighter hover:opacity-95 transition-opacity">
|
|
<span className="text-[#0073EC] text-2xl font-black leading-none">iFix</span>
|
|
<span className="text-gray-900 text-2xl font-black leading-none">Kart</span>
|
|
</Link>
|
|
<div className="h-5 w-px bg-gray-200 hidden sm:block" />
|
|
<span className="text-sm font-bold text-gray-700 hidden sm:inline-block">
|
|
{category.name}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Quick Search Model Input Box */}
|
|
<div className="relative w-full sm:w-64">
|
|
<input
|
|
type="text"
|
|
placeholder="Select Model..."
|
|
value={modelSearchQuery}
|
|
onChange={(e) => setModelSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-4 py-2 bg-gray-50 border border-gray-200 rounded-xl text-xs text-gray-800 focus:outline-none focus:border-gray-400 focus:bg-white transition-all placeholder:text-gray-400"
|
|
/>
|
|
<Search className="w-4 h-4 text-gray-400 absolute left-3 top-2.5" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Level 1: Select Brand Cards (Image 3) */}
|
|
<BrandSelector
|
|
brands={brands}
|
|
selectedBrandId={selectedBrandObj?.brand_id || null}
|
|
onSelectBrand={handleSelectBrand}
|
|
/>
|
|
|
|
{/* Level 2: Select Series Pills (Image 2) */}
|
|
{selectedBrandObj && seriesList.length > 0 && (
|
|
<SeriesSelector
|
|
seriesList={seriesList}
|
|
selectedSeriesId={selectedSeriesObj?.series_id || null}
|
|
onSelectSeries={handleSelectSeries}
|
|
/>
|
|
)}
|
|
|
|
{/* Level 3: Select Model Cards (Image 2) */}
|
|
{selectedBrandObj && modelsList.length > 0 && (
|
|
<ModelSelector
|
|
models={modelsList}
|
|
selectedModelId={selectedModelObj?.model_id || null}
|
|
onSelectModel={handleSelectModel}
|
|
/>
|
|
)}
|
|
|
|
{/* Filtered Products Section */}
|
|
<div className="mt-10 pt-6 border-t border-gray-100">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h2 className="text-xl font-extrabold text-gray-900">
|
|
{selectedModelObj
|
|
? `${selectedModelObj.name} Products`
|
|
: selectedSeriesObj
|
|
? `${selectedSeriesObj.name} Products`
|
|
: selectedBrandObj
|
|
? `${selectedBrandObj.name} Products`
|
|
: `All ${category.name} Products`}
|
|
</h2>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Showing {products.length} of {totalProducts} items
|
|
</p>
|
|
</div>
|
|
|
|
{(selectedBrandObj || selectedSeriesObj || selectedModelObj) && (
|
|
<button
|
|
onClick={() => handleSelectBrand(null)}
|
|
className="text-xs font-bold text-red-600 hover:text-red-700 flex items-center gap-1.5 transition-colors"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" /> Reset Selection
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Products Grid */}
|
|
{isProductsLoading ? (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
|
|
{[...Array(8)].map((_, i) => (
|
|
<div key={i} className="bg-gray-50 rounded-2xl p-4 animate-pulse h-80" />
|
|
))}
|
|
</div>
|
|
) : products.length === 0 ? (
|
|
<div className="bg-gray-50 rounded-2xl p-12 text-center border border-gray-100">
|
|
<p className="text-gray-500 font-medium text-sm">
|
|
No products available for this selection.
|
|
</p>
|
|
<button
|
|
onClick={() => handleSelectBrand(null)}
|
|
className="mt-4 text-xs font-bold text-red-600 hover:text-red-700 underline"
|
|
>
|
|
View All Category Products
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-6">
|
|
{products.map((p) => {
|
|
const firstVariant = p.variants?.[0];
|
|
const { price, oldPrice: comparePrice } = getCatalogPriceInfo(p);
|
|
const primaryImage = getImageUrl(p.images?.[0]?.image_url);
|
|
|
|
const isWishlisted = wishlist.some((item) => item.product_id === p.product_id);
|
|
const isCompared = compareList.some((item) => item.product_id === p.product_id);
|
|
|
|
return (
|
|
<div
|
|
key={p.product_id}
|
|
className="group bg-white rounded-2xl border border-gray-200 p-4 shadow-xs hover:shadow-lg hover:border-gray-300 transition-all duration-300 flex flex-col justify-between"
|
|
>
|
|
<div className="relative aspect-square mb-3 bg-gray-50 rounded-xl overflow-hidden">
|
|
<BlurHashImage
|
|
src={primaryImage}
|
|
alt={p.name}
|
|
fill
|
|
className="object-contain p-2 group-hover:scale-105 transition-transform duration-300"
|
|
/>
|
|
{p.badge && (
|
|
<span className="absolute top-2 left-2 bg-red-600 text-white text-[10px] font-bold px-2.5 py-0.5 rounded-full">
|
|
{p.badge}
|
|
</span>
|
|
)}
|
|
|
|
<div className="absolute top-2 right-2 flex flex-col gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
|
<button
|
|
onClick={() => toggleWishlist(p)}
|
|
className={`p-1.5 rounded-full bg-white shadow-sm border border-gray-100 text-gray-600 hover:text-red-600 transition-colors ${
|
|
isWishlisted ? 'text-red-600 fill-red-600' : ''
|
|
}`}
|
|
>
|
|
<Heart className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
void addToCompareAndNavigate(p, (href) => router.push(href));
|
|
}}
|
|
className={`p-1.5 rounded-full bg-white shadow-sm border border-gray-100 text-gray-600 hover:text-blue-600 transition-colors ${
|
|
isCompared ? 'text-blue-600' : ''
|
|
}`}
|
|
>
|
|
<ArrowLeftRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex flex-col justify-between">
|
|
<div>
|
|
<Link href={`/products/${p.slug}`}>
|
|
<h4 className="text-xs font-bold text-gray-900 hover:text-red-600 transition-colors line-clamp-2 mb-2">
|
|
{p.name}
|
|
</h4>
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mt-2 pt-2 border-t border-gray-100 flex items-center justify-between">
|
|
<div>
|
|
<div className="text-sm font-extrabold text-gray-900">{formatCurrency(price)}</div>
|
|
{comparePrice && comparePrice > price && (
|
|
<div className="text-[11px] text-gray-400 line-through">{formatCurrency(comparePrice)}</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => addToCart(p, firstVariant)}
|
|
className="p-2 bg-gray-900 text-white rounded-xl hover:bg-red-600 transition-colors"
|
|
title="Add to Cart"
|
|
>
|
|
<ShoppingCart className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|