ifixkart-storefront/components/catalog/ParentFeatureCatalog.tsx

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>
);
}