← back to Watches
src/components/AdvancedFilters.jsx
261 lines
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { FiFilter, FiX, FiChevronDown, FiSearch } from 'react-icons/fi';
/**
* Advanced filtering component with multi-select and range filters
* Features: price range, year range, movement type, series, sorting
*/
function AdvancedFilters({ watches, onFilterChange, className = '' }) {
const [isExpanded, setIsExpanded] = useState(false);
const [filters, setFilters] = useState({
searchTerm: '',
series: [],
movementTypes: [],
priceRange: [0, 100000],
yearRange: [1948, 2024],
sortBy: 'appreciation-desc'
});
// Extract unique values from watches
const filterOptions = useMemo(() => {
const series = [...new Set(watches.map(w => w.series))].sort();
const movements = [...new Set(watches.map(w => {
const movement = w.specifications?.movement || '';
if (movement.includes('Automatic')) return 'Automatic';
if (movement.includes('Manual')) return 'Manual';
if (movement.includes('Quartz')) return 'Quartz';
return 'Other';
}))].filter(Boolean).sort();
const prices = watches.map(w => w.priceHistory[w.priceHistory.length - 1]?.price || 0);
const years = watches.map(w => w.yearIntroduced);
return {
series,
movements,
minPrice: Math.floor(Math.min(...prices) / 1000) * 1000,
maxPrice: Math.ceil(Math.max(...prices) / 1000) * 1000,
minYear: Math.min(...years),
maxYear: Math.max(...years)
};
}, [watches]);
const updateFilter = (key, value) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
onFilterChange(newFilters);
};
const toggleArrayFilter = (key, value) => {
const current = filters[key];
const newValue = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
updateFilter(key, newValue);
};
const clearFilters = () => {
const defaultFilters = {
searchTerm: '',
series: [],
movementTypes: [],
priceRange: [filterOptions.minPrice, filterOptions.maxPrice],
yearRange: [filterOptions.minYear, filterOptions.maxYear],
sortBy: 'appreciation-desc'
};
setFilters(defaultFilters);
onFilterChange(defaultFilters);
};
const activeFilterCount =
filters.series.length +
filters.movementTypes.length +
(filters.searchTerm ? 1 : 0);
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-md ${className}`}>
{/* Filter Header */}
<div
className="p-4 flex items-center justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center space-x-3">
<FiFilter className="text-omega-red text-xl" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-white">
Advanced Filters
</h3>
{activeFilterCount > 0 && (
<span className="bg-omega-red text-white text-xs font-bold px-2 py-1 rounded-full">
{activeFilterCount}
</span>
)}
</div>
<div className="flex items-center space-x-2">
{activeFilterCount > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
clearFilters();
}}
className="text-sm text-gray-500 hover:text-omega-red transition-colors"
>
Clear All
</button>
)}
<FiChevronDown
className={`text-gray-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</div>
</div>
{/* Expanded Filters */}
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="overflow-hidden border-t border-gray-200 dark:border-gray-700"
>
<div className="p-6 space-y-6">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search
</label>
<div className="relative">
<FiSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by model, reference, or specs..."
value={filters.searchTerm}
onChange={(e) => updateFilter('searchTerm', e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-omega-red focus:border-transparent dark:bg-gray-700 dark:text-white"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Series Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Series
</label>
<div className="space-y-2 max-h-40 overflow-y-auto">
{filterOptions.series.map(series => (
<label key={series} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={filters.series.includes(series)}
onChange={() => toggleArrayFilter('series', series)}
className="w-4 h-4 text-omega-red border-gray-300 rounded focus:ring-omega-red"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{series}</span>
</label>
))}
</div>
</div>
{/* Movement Type Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Movement Type
</label>
<div className="space-y-2">
{filterOptions.movements.map(movement => (
<label key={movement} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={filters.movementTypes.includes(movement)}
onChange={() => toggleArrayFilter('movementTypes', movement)}
className="w-4 h-4 text-omega-red border-gray-300 rounded focus:ring-omega-red"
/>
<span className="text-sm text-gray-700 dark:text-gray-300">{movement}</span>
</label>
))}
</div>
</div>
</div>
{/* Price Range */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Price Range: ${filters.priceRange[0].toLocaleString()} - ${filters.priceRange[1].toLocaleString()}
</label>
<div className="flex items-center space-x-4">
<input
type="range"
min={filterOptions.minPrice}
max={filterOptions.maxPrice}
value={filters.priceRange[0]}
onChange={(e) => updateFilter('priceRange', [parseInt(e.target.value), filters.priceRange[1]])}
className="flex-1"
/>
<input
type="range"
min={filterOptions.minPrice}
max={filterOptions.maxPrice}
value={filters.priceRange[1]}
onChange={(e) => updateFilter('priceRange', [filters.priceRange[0], parseInt(e.target.value)])}
className="flex-1"
/>
</div>
</div>
{/* Year Range */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Year Range: {filters.yearRange[0]} - {filters.yearRange[1]}
</label>
<div className="flex items-center space-x-4">
<input
type="range"
min={filterOptions.minYear}
max={filterOptions.maxYear}
value={filters.yearRange[0]}
onChange={(e) => updateFilter('yearRange', [parseInt(e.target.value), filters.yearRange[1]])}
className="flex-1"
/>
<input
type="range"
min={filterOptions.minYear}
max={filterOptions.maxYear}
value={filters.yearRange[1]}
onChange={(e) => updateFilter('yearRange', [filters.yearRange[0], parseInt(e.target.value)])}
className="flex-1"
/>
</div>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Sort By
</label>
<select
value={filters.sortBy}
onChange={(e) => updateFilter('sortBy', e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-omega-red focus:border-transparent dark:bg-gray-700 dark:text-white"
>
<option value="appreciation-desc">Highest Appreciation</option>
<option value="appreciation-asc">Lowest Appreciation</option>
<option value="price-desc">Highest Price</option>
<option value="price-asc">Lowest Price</option>
<option value="year-desc">Newest First</option>
<option value="year-asc">Oldest First</option>
<option value="name-asc">Name (A-Z)</option>
<option value="name-desc">Name (Z-A)</option>
</select>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default AdvancedFilters;