ifixkart-storefront/components/catalog/StandardCatalog.tsx

788 lines
29 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import {
catalogService,
ProductResponse,
CategoryResponse,
BrandResponse,
} from '@/services/api/catalogService';
import { formatCurrency, getCatalogPriceInfo, getImageUrl } from '@/lib/utils';
import {
applyCatalogFilters,
CATALOG_API_LIMIT,
CATALOG_MAX_API_PAGES,
CATALOG_PAGE_SIZE,
CATALOG_PRICE_RANGES,
getDisplayStarCount,
productMatchesRating,
type CatalogHighlight,
} from '@/lib/catalogFilters';
import {
LayoutGrid,
List,
Minus,
Plus,
Heart,
Eye,
GitCompare,
ShoppingCart,
Star,
ChevronDown,
} from 'lucide-react';
import { useCartStore } from '@/store/cartStore';
import { AnnouncementBar } from '@/layout/AnnouncementBar';
import { Header } from '@/layout/Header';
import { Navbar } from '@/layout/Navbar';
import { Footer } from '@/layout/Footer';
import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer';
import { FloatingButtons } from '@/components/FloatingButtons';
import { ProductCardSkeleton } from '@/components/ui/Skeleton';
import { mapProductCardToProductResponse } from '@/utils/productMapper';
import { addToCompareAndNavigate } from '@/lib/compare';
import BlurHashImage from '@/components/ui/BlurHashImage';
interface StandardCatalogProps {
initialCategory?: CategoryResponse | null;
categoryParam?: string | null;
brandParam?: string | null;
searchQuery?: string | null;
}
const HIGHLIGHTS: CatalogHighlight[] = [
'All Products',
'Best Seller',
'New Arrivals',
'Sale',
'Hot Items',
];
type CollapseKey = 'categories' | 'brands' | 'highlight' | 'price' | 'rating';
function mapApiProduct(p: any): ProductResponse {
return mapProductCardToProductResponse(p);
}
function CatalogProductCard({
product,
viewMode,
}: {
product: ProductResponse;
viewMode: 'grid' | 'list';
}) {
const addToCart = useCartStore((s) => s.addToCart);
const toggleWishlist = useCartStore((s) => s.toggleWishlist);
const setQuickViewProduct = useCartStore((s) => s.setQuickViewProduct);
const router = useRouter();
const wishlist = useCartStore((s) => s.wishlist) || [];
const compareList = useCartStore((s) => s.compareList) || [];
const variant = product.variants?.[0];
const { price, oldPrice, discount: mappedDiscount } = getCatalogPriceInfo(product);
const compare = oldPrice || 0;
const discount =
mappedDiscount ||
(compare > price ? Math.round(((compare - price) / compare) * 100) : 0);
const image = getImageUrl(product.images[0]?.image_url);
const rating = getDisplayStarCount(product);
const isWishlisted = wishlist.some((i) => i.product_id === product.product_id);
const isCompared = compareList.some((i) => i.product_id === product.product_id);
const hasOptions = (product.variants?.length || 0) > 1;
const ctaLabel = hasOptions ? 'Select Options' : 'Add To Cart';
const handleCart = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!variant) return;
if (hasOptions) {
window.location.href = `/products/${product.slug}`;
return;
}
void addToCart(product, variant, 1);
};
if (viewMode === 'list') {
return (
<div className="flex gap-5 p-5 border-b border-[#e8e8e8] bg-white hover:bg-[#fafafa] transition-colors group">
<Link href={`/products/${product.slug}`} className="relative w-36 h-36 shrink-0 bg-white">
{discount > 0 && (
<span className="absolute top-0 right-0 z-10 bg-primary text-white text-[10px] font-bold px-1.5 py-0.5">
-{discount}%
</span>
)}
{image ? (
<BlurHashImage src={image} alt={product.name} fill className="object-contain p-2" />
) : (
<div className="w-full h-full bg-gray-50" />
)}
</Link>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<Link
href={`/products/${product.slug}`}
className="text-[14px] font-semibold text-[#222] hover:text-primary line-clamp-2 mb-1.5"
>
{product.name}
</Link>
<div className="flex items-center gap-0.5 text-amber-400 mb-2">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} size={12} className={i < rating ? 'fill-amber-400' : 'text-gray-200'} />
))}
</div>
<div className="flex items-center gap-2 mb-3">
<span className="text-[15px] font-bold text-[#111]">{formatCurrency(price)}</span>
{compare > price && (
<span className="text-[12px] text-gray-400 line-through">{formatCurrency(compare)}</span>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCart}
className="w-fit px-5 py-2 bg-primary text-white text-[12px] font-semibold rounded-md hover:brightness-95 cursor-pointer"
>
{ctaLabel}
</button>
<button
type="button"
aria-label="Compare"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void addToCompareAndNavigate(product, (href) => router.push(href));
}}
className={`w-9 h-9 rounded-md border flex items-center justify-center cursor-pointer ${
isCompared ? 'border-primary text-primary bg-primary/5' : 'border-gray-200 text-gray-500 hover:border-primary hover:text-primary'
}`}
>
<GitCompare size={14} />
</button>
</div>
</div>
</div>
);
}
return (
<div className="group relative flex flex-col bg-white p-4 md:p-5 min-h-[340px] border-r border-b border-[#e8e8e8]">
{discount > 0 && (
<span className="absolute top-3 right-3 z-10 bg-primary text-white text-[10px] font-bold px-1.5 py-0.5">
-{discount}%
</span>
)}
<div className="absolute left-3 top-3 z-20 flex flex-col gap-1.5 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200">
<button
type="button"
aria-label="Wishlist"
onClick={(e) => {
e.preventDefault();
toggleWishlist(product);
}}
className={`w-8 h-8 rounded-full border bg-white flex items-center justify-center shadow-sm cursor-pointer ${
isWishlisted ? 'border-primary text-primary' : 'border-gray-200 text-gray-500 hover:border-primary hover:text-primary'
}`}
>
<Heart size={13} className={isWishlisted ? 'fill-current' : ''} />
</button>
<button
type="button"
aria-label="Compare"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void addToCompareAndNavigate(product, (href) => router.push(href));
}}
className={`w-8 h-8 rounded-full border bg-white flex items-center justify-center shadow-sm cursor-pointer ${
isCompared ? 'border-primary text-primary' : 'border-gray-200 text-gray-500 hover:border-primary hover:text-primary'
}`}
>
<GitCompare size={13} />
</button>
<button
type="button"
aria-label="Quick view"
onClick={(e) => {
e.preventDefault();
setQuickViewProduct(product);
}}
className="w-8 h-8 rounded-full border border-gray-200 bg-white text-gray-500 hover:border-primary hover:text-primary flex items-center justify-center shadow-sm cursor-pointer"
>
<Eye size={13} />
</button>
</div>
<Link
href={`/products/${product.slug}`}
className="relative flex-1 flex items-center justify-center min-h-[160px] mb-3"
>
{image ? (
<BlurHashImage
src={image}
alt={product.name}
width={180}
height={160}
className="max-h-[150px] w-auto object-contain"
/>
) : (
<div className="w-24 h-24 bg-gray-50 rounded" />
)}
</Link>
<div className="text-left mt-auto">
<Link
href={`/products/${product.slug}`}
className="block text-[13px] font-semibold text-[#222] hover:text-primary line-clamp-2 leading-snug min-h-[36px] mb-1.5"
>
{product.name}
</Link>
<div className="flex items-center gap-0.5 text-amber-400 mb-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} size={12} className={i < rating ? 'fill-amber-400' : 'text-gray-200'} />
))}
</div>
<div className="flex items-center gap-2">
<span className="text-[14px] font-bold text-[#111]">{formatCurrency(price)}</span>
{compare > price && (
<span className="text-[12px] text-gray-400 line-through">{formatCurrency(compare)}</span>
)}
</div>
</div>
<div className="overflow-hidden transition-all duration-300 max-h-0 opacity-0 group-hover:max-h-12 group-hover:opacity-100 group-hover:mt-3">
<button
type="button"
onClick={handleCart}
className="w-full py-2.5 bg-primary text-white text-[12px] font-semibold rounded-md hover:brightness-95 cursor-pointer inline-flex items-center justify-center gap-1.5"
>
<ShoppingCart size={13} />
{ctaLabel}
</button>
</div>
</div>
);
}
function FilterCard({
title,
open,
onToggle,
children,
}: {
title: string;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="bg-white border border-[#e5e5e5] rounded-lg overflow-hidden">
<button
type="button"
onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-3.5 text-left cursor-pointer"
>
<span className="text-[14px] font-bold text-[#222]">{title}</span>
{open ? <Minus size={15} className="text-gray-500" /> : <Plus size={15} className="text-gray-500" />}
</button>
{open && <div className="px-4 pb-4 pt-0 border-t border-[#f0f0f0]">{children}</div>}
</div>
);
}
export function StandardCatalog({
initialCategory,
categoryParam,
brandParam,
searchQuery,
}: StandardCatalogProps) {
const [productPool, setProductPool] = useState<ProductResponse[]>([]);
const [categories, setCategories] = useState<CategoryResponse[]>([]);
const [brands, setBrands] = useState<BrandResponse[]>([]);
const [selectedCategory, setSelectedCategory] = useState('all');
const [selectedBrand, setSelectedBrand] = useState('all');
const [highlight, setHighlight] = useState<CatalogHighlight>('All Products');
const [priceRangeId, setPriceRangeId] = useState('all');
const [selectedRating, setSelectedRating] = useState<number | null>(null);
const [sortBy, setSortBy] = useState('default');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [page, setPage] = useState(1);
const [isLoading, setIsLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const fetchIdRef = useRef(0);
const [collapsed, setCollapsed] = useState<Record<CollapseKey, boolean>>({
categories: false,
brands: false,
highlight: false,
price: false,
rating: false,
});
const [allProductsTotal, setAllProductsTotal] = useState<number>(0);
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
useEffect(() => {
catalogService.getProductsPaginated({ limit: 1 }).then((res) => {
if (res && typeof res.total === 'number') {
setAllProductsTotal(res.total);
}
}).catch(() => {});
}, []);
const toggleCollapse = (key: CollapseKey) =>
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
useEffect(() => {
catalogService.getCategories().then((catData) => {
setCategories(catData);
const counts: Record<string, number> = {};
for (const c of catData) {
if (c.product_count != null) {
counts[c.category_id] = c.product_count;
counts[c.slug] = c.product_count;
}
}
setCategoryCounts(counts);
if (categoryParam) {
const found = catData.find(
(c) =>
c.slug.toLowerCase() === categoryParam.toLowerCase() ||
c.category_id === categoryParam
);
setSelectedCategory(found?.category_id || categoryParam);
} else if (initialCategory) {
setSelectedCategory(initialCategory.category_id);
} else {
setSelectedCategory('all');
}
});
}, [categoryParam, initialCategory]);
useEffect(() => {
const activeCatId = selectedCategory === 'all' ? undefined : selectedCategory;
catalogService.getBrands(activeCatId).then((brandData) => {
setBrands(brandData);
if (brandParam) {
const found = brandData.find(
(b) =>
b.slug.toLowerCase() === brandParam.toLowerCase() ||
b.brand_id === brandParam
);
setSelectedBrand(found?.brand_id || brandParam);
} else {
setSelectedBrand('all');
}
});
}, [selectedCategory, brandParam]);
useEffect(() => {
setPage(1);
}, [
selectedCategory,
selectedBrand,
searchQuery,
highlight,
priceRangeId,
selectedRating,
sortBy,
]);
useEffect(() => {
const fetchId = ++fetchIdRef.current;
setIsLoading(true);
setProductPool([]);
const loadAll = async () => {
const paramsBase: Record<string, string | number> = {
limit: CATALOG_API_LIMIT,
};
if (selectedCategory !== 'all') paramsBase.category = selectedCategory;
if (selectedBrand !== 'all') paramsBase.brand = selectedBrand;
if (searchQuery) paramsBase.search = searchQuery;
const accumulated: ProductResponse[] = [];
const seen = new Set<string>();
let apiPage = 1;
let total = Number.POSITIVE_INFINITY;
while (apiPage <= CATALOG_MAX_API_PAGES && accumulated.length < total) {
try {
const res = await catalogService.getProductsPaginated({
...paramsBase,
page: apiPage,
});
if (fetchId !== fetchIdRef.current) return;
total = typeof res.total === 'number' ? res.total : accumulated.length;
const mapped = (res.products || []).map(mapApiProduct);
if (mapped.length === 0) break;
let added = 0;
for (const product of mapped) {
if (seen.has(product.product_id)) continue;
seen.add(product.product_id);
accumulated.push(product);
added += 1;
}
if (added > 0) {
setProductPool([...accumulated]);
if (apiPage === 1) setIsLoading(false);
}
if (accumulated.length >= total || mapped.length < CATALOG_API_LIMIT) break;
apiPage += 1;
} catch {
break;
}
}
if (fetchId !== fetchIdRef.current) return;
setProductPool([...accumulated]);
setIsLoading(false);
};
void loadAll();
}, [selectedCategory, selectedBrand, searchQuery]);
const filteredProducts = useMemo(() => {
return applyCatalogFilters(productPool, {
priceRangeId,
selectedRating,
highlight,
sortBy,
});
}, [productPool, priceRangeId, selectedRating, highlight, sortBy]);
const ratingFilterPool = useMemo(
() =>
applyCatalogFilters(productPool, {
priceRangeId,
selectedRating: null,
highlight,
sortBy: 'default',
}),
[productPool, priceRangeId, highlight]
);
const displayed = useMemo(
() => filteredProducts.slice(0, page * CATALOG_PAGE_SIZE),
[filteredProducts, page]
);
const totalProducts = filteredProducts.length;
const showingFrom = displayed.length === 0 ? 0 : 1;
const showingTo = displayed.length;
const hasMore = displayed.length < totalProducts;
const resetFilters = () => {
setSelectedCategory('all');
setSelectedBrand('all');
setHighlight('All Products');
setPriceRangeId('all');
setSelectedRating(null);
setSortBy('default');
setPage(1);
};
const handleLoadMore = () => {
setLoadingMore(true);
setPage((p) => p + 1);
// next paint after slice grows
requestAnimationFrame(() => setLoadingMore(false));
};
return (
<div className="min-h-screen bg-white text-[#1E293B] flex flex-col font-sans">
<AnnouncementBar />
<Header />
<StickyHeaderSpacer />
<Navbar />
<main className="flex-grow pb-16">
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-6 max-w-[1400px]">
<div className="flex flex-col lg:flex-row gap-7 items-start">
<aside className="w-full lg:w-[260px] shrink-0 space-y-4">
<FilterCard
title="Shop By Categories"
open={!collapsed.categories}
onToggle={() => toggleCollapse('categories')}
>
<div className="pt-3 space-y-2.5">
<label className="flex items-center justify-between text-[13px] cursor-pointer group">
<span className="flex items-center gap-2.5">
<input
type="checkbox"
checked={selectedCategory === 'all'}
onChange={() => setSelectedCategory('all')}
className="w-3.5 h-3.5 rounded border-gray-300 text-primary accent-primary cursor-pointer"
/>
<span className={selectedCategory === 'all' ? 'text-primary font-semibold' : 'text-gray-600'}>
Our Store
</span>
</span>
<span className="text-gray-400">({allProductsTotal})</span>
</label>
{categories.map((cat) => {
const checked =
selectedCategory === cat.category_id ||
selectedCategory.toLowerCase() === cat.slug.toLowerCase();
const catCount =
categoryCounts[cat.category_id] ??
categoryCounts[cat.slug] ??
(cat as any).product_count;
return (
<label
key={cat.category_id}
className="flex items-center justify-between text-[13px] cursor-pointer"
>
<span className="flex items-center gap-2.5 min-w-0">
<input
type="checkbox"
checked={checked}
onChange={() => setSelectedCategory(checked ? 'all' : cat.category_id)}
className="w-3.5 h-3.5 rounded border-gray-300 text-primary accent-primary cursor-pointer"
/>
<span className={`truncate ${checked ? 'text-primary font-semibold' : 'text-gray-600'}`}>
{cat.name}
</span>
</span>
{catCount != null && (
<span className="text-gray-400 shrink-0">
({catCount})
</span>
)}
</label>
);
})}
</div>
</FilterCard>
<FilterCard title="Brands" open={!collapsed.brands} onToggle={() => toggleCollapse('brands')}>
<div className="pt-3 space-y-2.5 max-h-56 overflow-y-auto">
<label className="flex items-center gap-2.5 text-[13px] cursor-pointer">
<input
type="checkbox"
checked={selectedBrand === 'all'}
onChange={() => setSelectedBrand('all')}
className="w-3.5 h-3.5 rounded border-gray-300 accent-primary cursor-pointer"
/>
<span className={selectedBrand === 'all' ? 'text-primary font-semibold' : 'text-gray-600'}>
All Brands
</span>
</label>
{brands.map((brand) => {
const checked = selectedBrand === brand.brand_id;
return (
<label key={brand.brand_id} className="flex items-center gap-2.5 text-[13px] cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() => setSelectedBrand(checked ? 'all' : brand.brand_id)}
className="w-3.5 h-3.5 rounded border-gray-300 accent-primary cursor-pointer"
/>
<span className={checked ? 'text-primary font-semibold' : 'text-gray-600'}>
{brand.name}
</span>
</label>
);
})}
</div>
</FilterCard>
<FilterCard
title="Highlight"
open={!collapsed.highlight}
onToggle={() => toggleCollapse('highlight')}
>
<div className="pt-3 space-y-2">
{HIGHLIGHTS.map((item) => (
<button
key={item}
type="button"
onClick={() => setHighlight(item)}
className={`block w-full text-left text-[13px] py-0.5 cursor-pointer ${
highlight === item ? 'text-primary font-semibold' : 'text-gray-600 hover:text-primary'
}`}
>
{item}
</button>
))}
</div>
</FilterCard>
<FilterCard title="Price Filter" open={!collapsed.price} onToggle={() => toggleCollapse('price')}>
<div className="pt-3 space-y-2">
{CATALOG_PRICE_RANGES.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceRangeId(range.id)}
className={`block w-full text-left text-[13px] py-0.5 cursor-pointer ${
priceRangeId === range.id
? 'text-primary font-semibold'
: 'text-gray-600 hover:text-primary'
}`}
>
{range.label}
</button>
))}
</div>
</FilterCard>
<FilterCard
title="Average rating"
open={!collapsed.rating}
onToggle={() => toggleCollapse('rating')}
>
<div className="pt-3 space-y-1">
{[5, 4, 3, 2, 1].map((stars) => {
const active = selectedRating === stars;
const matchCount = ratingFilterPool.filter((p) =>
productMatchesRating(p, stars)
).length;
return (
<button
key={stars}
type="button"
aria-pressed={active}
onClick={() =>
setSelectedRating(selectedRating === stars ? null : stars)
}
className={`flex items-center justify-between w-full rounded-md px-2 py-1.5 -mx-2 text-[13px] cursor-pointer ${
active
? 'bg-[#f3f6fb] text-primary ring-1 ring-[#d7e3f4]'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
<span className="flex items-center gap-0.5 text-amber-400">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
size={12}
className={i < stars ? 'fill-amber-400' : 'text-gray-200'}
/>
))}
</span>
<span className={active ? 'text-primary' : 'text-gray-400'}>
({stars}+) {isLoading ? '' : matchCount}
</span>
</button>
);
})}
</div>
</FilterCard>
</aside>
<div className="flex-1 min-w-0 w-full">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-5">
<p className="text-[13px] text-gray-600">
Showing{' '}
<span className="font-semibold text-gray-900">
{showingFrom}{showingTo}
</span>{' '}
of <span className="font-semibold text-gray-900">{totalProducts}</span> results
</p>
<div className="flex items-center gap-3">
<div className="relative">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="appearance-none bg-white border border-[#e5e5e5] rounded-md pl-3 pr-8 py-2 text-[13px] text-gray-700 cursor-pointer focus:outline-none focus:border-primary"
>
<option value="default">Default sorting</option>
<option value="newest">Newest</option>
<option value="price-low">Price: low to high</option>
<option value="price-high">Price: high to low</option>
<option value="a-z">Name: AZ</option>
</select>
<ChevronDown
size={13}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setViewMode('grid')}
className={`p-2 rounded-sm cursor-pointer ${
viewMode === 'grid' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500'
}`}
aria-label="Grid view"
>
<LayoutGrid size={15} />
</button>
<button
type="button"
onClick={() => setViewMode('list')}
className={`p-2 rounded-sm cursor-pointer ${
viewMode === 'list' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-500'
}`}
aria-label="List view"
>
<List size={15} />
</button>
</div>
</div>
</div>
{isLoading ? (
<div className="border border-[#e8e8e8] grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="border-r border-b border-[#e8e8e8]"
>
<ProductCardSkeleton />
</div>
))}
</div>
) : displayed.length === 0 ? (
<div className="border border-[#e8e8e8] rounded-lg p-12 text-center">
<p className="text-sm text-gray-500 mb-4">No products found matching your filters.</p>
<button
type="button"
onClick={resetFilters}
className="text-[13px] font-semibold text-primary hover:underline cursor-pointer"
>
Clear all filters
</button>
</div>
) : viewMode === 'grid' ? (
<div className="border border-[#e8e8e8] border-b-0 border-r-0 grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{displayed.map((p) => (
<CatalogProductCard key={p.product_id} product={p} viewMode="grid" />
))}
</div>
) : (
<div className="border border-[#e8e8e8] rounded-sm overflow-hidden">
{displayed.map((p) => (
<CatalogProductCard key={p.product_id} product={p} viewMode="list" />
))}
</div>
)}
{hasMore && !isLoading && (
<div className="flex justify-center mt-8">
<button
type="button"
disabled={loadingMore}
onClick={handleLoadMore}
className="px-10 py-3 bg-primary text-white text-[13px] font-semibold rounded-md hover:brightness-95 disabled:opacity-60 cursor-pointer"
>
{loadingMore ? 'Loading…' : 'Load More'}
</button>
</div>
)}
</div>
</div>
</div>
</main>
<Footer />
<FloatingButtons />
</div>
);
}