788 lines
29 KiB
TypeScript
788 lines
29 KiB
TypeScript
'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: A–Z</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>
|
||
);
|
||
}
|