Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/api/productService.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,32 @@ export const getProducts = async (params = {}) => {
if (params.sortBy) apiParams.sortBy = params.sortBy;
if (params.sortOrder) apiParams.sortOrder = params.sortOrder;

const response = await axiosInstance.get('/products', { params: apiParams });
let response;
if (params.sort) {
const sortKey = encodeURIComponent(params.sort);
response = await axiosInstance.get(`/products/sort/${sortKey}`, { params: apiParams });
} else {
response = await axiosInstance.get('/products', { params: apiParams });
}

const data = response.data;

if (Array.isArray(data)) {
return {
success: true,
data: {
products: data,
total: data.length,
page: 1,
pages: 1,
},
status: response.status,
};
}

return {
success: true,
data: transformApiResponse(response.data),
data: transformApiResponse(data),
status: response.status,
};
} catch (error) {
Expand Down
25 changes: 20 additions & 5 deletions src/components/Products/ProductCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,26 @@ const ProductCard = forwardRef(({ product }, ref) => {
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<div className="flex">
{[...Array(5)].map((_, i) => (
<svg key={i} className={`w-4 h-4 ${i < Math.floor(product.rating || 0) ? 'text-yellow-400' : 'text-gray-300'}`} fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
{[0, 1, 2, 3, 4].map((i) => {
const ratingValue = Number(product.rating) || 0;
const fill = Math.max(0, Math.min(1, ratingValue - i)); // 0..1
const pct = Math.round(fill * 100);
return (
<div key={i} className="relative w-4 h-4 mr-0.5">
{/* Gray background star */}
<svg className="w-4 h-4 text-gray-300 absolute inset-0" fill="currentColor" viewBox="0 0 20 20" aria-hidden>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>

{/* Yellow foreground star clipped to pct width */}
<div className="absolute inset-0 overflow-hidden" style={{ width: `${pct}%` }} aria-hidden>
<svg className="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
);
})}
</div>
<span className="text-xs text-gray-500">({product.reviewCount || 0})</span>
</div>
Expand Down
49 changes: 29 additions & 20 deletions src/components/Products/SortDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,29 +18,20 @@ export default function SortDropdown({ sortBy = 'featured', sortOrder = 'desc',
option => option.field === sortBy && option.order === sortOrder
) || SORT_OPTIONS[0];

// This hook now handles clicks outside AND page scrolling
// Handle clicks outside to close the dropdown. Do NOT close on scroll — UX requires it remain open until user closes.
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};

// A simple function to close the dropdown
const handleScroll = () => {
setIsOpen(false);
};

// Add event listeners only when the dropdown is open
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('scroll', handleScroll, true); // Added scroll listener
}

// Cleanup: remove both event listeners
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('scroll', handleScroll, true); // Removed scroll listener
};
}, [isOpen]);

Expand All @@ -50,16 +41,16 @@ export default function SortDropdown({ sortBy = 'featured', sortOrder = 'desc',
};

return (
<div className={`flex items-center gap-2 sm:gap-4 ${className}`}>
<div className="relative flex-shrink-0">
<div ref={dropdownRef} className={`relative flex items-center gap-2 sm:gap-4 ${className}`}>
<div className="flex-shrink-0">
<span className="font-semibold text-lg text-gray-800">Sort by:</span>
<div className="absolute -bottom-1 left-0 w-12 h-0.5 bg-gray-800" />
<div className="absolute left-0 w-12 h-0.5 bg-gray-800" />
</div>

<div className="relative flex-1 sm:flex-initial" ref={dropdownRef}>
<div className="flex-1 sm:flex-initial">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full sm:w-[240px] flex items-center justify-between gap-2 bg-transparent text-gray-700 px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-semibold border-2 border-gray-300 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50 transition-all duration-300"
className="w-full sm:w-[240px] flex items-center justify-between gap-2 bg-transparent text-gray-700 px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-semibold border-2 border-gray-300 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50 transition-all duration-300 cursor-pointer"
aria-haspopup="true"
aria-expanded={isOpen}
>
Expand All @@ -69,8 +60,26 @@ export default function SortDropdown({ sortBy = 'featured', sortOrder = 'desc',
</svg>
</button>

{isOpen && (
<div className="absolute left-0 z-50 w-full sm:w-64 mt-2 origin-top-left sm:origin-top-right bg-white border border-gray-200 rounded-lg shadow-xl">
<div
className={`absolute top-0 left-0 right-0 z-20 w-full bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden transition-[max-height,opacity] duration-300 ease-out ${
isOpen ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}
aria-hidden={!isOpen}
>
{/* Header with title and X close button */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div className="text-xs font-semibold text-gray-800 uppercase">Sort by</div>
<button
onClick={() => setIsOpen(false)}
aria-label="Close sort menu"
className="text-gray-500 hover:text-gray-800 focus:outline-none ml-2 cursor-pointer border rounded-full p-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>

<div className="py-2">
{SORT_OPTIONS.map((option, index) => (
<button
Expand All @@ -80,7 +89,7 @@ export default function SortDropdown({ sortBy = 'featured', sortOrder = 'desc',
option.field === sortBy && option.order === sortOrder
? 'bg-blue-50 text-blue-700 font-semibold'
: 'text-gray-700'
}`}
} cursor-pointer`}
role="menuitem"
>
<span>{option.label}</span>
Expand All @@ -93,8 +102,8 @@ export default function SortDropdown({ sortBy = 'featured', sortOrder = 'desc',
))}
</div>
</div>
)}
</div>
</div>
);
}

}
69 changes: 19 additions & 50 deletions src/pages/Products/Products.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,51 +70,7 @@ export default function Products() {
});
}, [allProducts, filters, maxProductPrice]);

// Apply client-side sorting to filtered products
const sortedProducts = useMemo(() => {
if (!filteredProducts || filteredProducts.length === 0) return [];

// shallow copy to avoid mutating original array
const copy = [...filteredProducts];

const getString = (p, key) => (p[key] || p.name || p.title || '').toString().toLowerCase();
const getNumber = (p, key) => {
const val = p[key];
if (val === undefined || val === null || val === '') return 0;
const n = Number(val);
return Number.isNaN(n) ? 0 : n;
};

switch (`${sortBy}:${sortOrder}`) {
case 'title:asc':
copy.sort((a, b) => getString(a, 'name').localeCompare(getString(b, 'name')));
break;
case 'title:desc':
copy.sort((a, b) => getString(b, 'name').localeCompare(getString(a, 'name')));
break;
case 'price:asc':
copy.sort((a, b) => getNumber(a, 'price') - getNumber(b, 'price'));
break;
case 'price:desc':
copy.sort((a, b) => getNumber(b, 'price') - getNumber(a, 'price'));
break;
case 'rating:asc':
copy.sort((a, b) => getNumber(a, 'rating') - getNumber(b, 'rating'));
break;
case 'rating:desc':
copy.sort((a, b) => getNumber(b, 'rating') - getNumber(a, 'rating'));
break;
case 'featured:desc':
default:
// Featured (placeholder) - keep backend/default ordering
break;
}

return copy;
}, [filteredProducts, sortBy, sortOrder]);

// Simple display of filtered products with pagination/infinite scroll
const displayedProducts = sortedProducts.slice(0, displayedCount);
const displayedProducts = filteredProducts.slice(0, displayedCount);
const hasMoreProducts = displayedCount < filteredProducts.length;

// Infinite scroll callback
Expand All @@ -135,7 +91,7 @@ export default function Products() {
);

useEffect(() => {
dispatch(fetchProducts({ page: 1, limit: 1000 }));
dispatch(fetchProducts({ page: 1, limit: 1000, sort: 'feature' }));
}, [dispatch]);

return (
Expand Down Expand Up @@ -174,7 +130,7 @@ export default function Products() {
<section className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
{/* Left: All Filters button (full width on mobile) */}
<button onClick={() => setIsFilterOpen(true)} className="w-full sm:w-auto flex items-center justify-center gap-2 bg-transparent text-gray-700 px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-medium border border-gray-300 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50 transition-all duration-300">
<button onClick={() => setIsFilterOpen(true)} className="w-full sm:w-auto flex items-center justify-center gap-2 bg-transparent text-gray-700 px-4 py-2 sm:px-6 sm:py-3 rounded-lg font-medium border border-gray-300 hover:border-blue-500 hover:text-blue-600 hover:bg-blue-50 transition-all duration-300 cursor-pointer">
<span className="hidden sm:inline">All Filters</span>
<span className="sm:hidden">Filters</span>
<svg
Expand All @@ -197,11 +153,24 @@ export default function Products() {
sortBy={sortBy}
sortOrder={sortOrder}
onSortChange={(field, order) => {
// If user chooses Featured (placeholder), map empty or featured
setSortBy(field || 'featured');
// Update local sort state
setSortBy(field || 'featured');
setSortOrder(order || 'desc');
// reset displayed count to show top results
setDisplayedCount(12);

// Map selection to backend sort key
// Supported backend keys: feature (default), a-z, z-a, price_asc, price_desc, rating_asc, rating_desc
let sortKey = 'feature';
if (field === 'title' && order === 'asc') sortKey = 'a-z';
else if (field === 'title' && order === 'desc') sortKey = 'z-a';
else if (field === 'price' && order === 'asc') sortKey = 'price_asc';
else if (field === 'price' && order === 'desc') sortKey = 'price_desc';
else if (field === 'rating' && order === 'asc') sortKey = 'rating_asc';
else if (field === 'rating' && order === 'desc') sortKey = 'rating_desc';
else sortKey = 'feature';

// Fetch products from backend with sort
dispatch(fetchProducts({ page: 1, limit: 1000, sort: sortKey }));
}}
/>
</div>
Expand Down