← back to Watches
src/components/EnhancedWatchList.jsx
419 lines
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import Fuse from 'fuse.js';
import AdvancedFilters from './AdvancedFilters';
import { WatchGridSkeleton } from './SkeletonLoader';
import { FavoriteButton, ShareButton, ExportButton, useFavorites } from './FavoritesManager';
import { FiGrid, FiList, FiTrendingUp, FiTrendingDown } from 'react-icons/fi';
/**
* Enhanced watch list with advanced filtering, sorting, fuzzy search, and animations
*/
function EnhancedWatchList({ watches, onWatchSelect, loading = false, onCompare }) {
const [viewMode, setViewMode] = useState('grid'); // grid or list
const [filters, setFilters] = useState({
searchTerm: '',
series: [],
movementTypes: [],
priceRange: [0, 100000],
yearRange: [1948, 2024],
sortBy: 'appreciation-desc'
});
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const { favorites, toggleFavorite, isFavorite } = useFavorites();
// Setup fuzzy search
const fuse = useMemo(() => {
return new Fuse(watches, {
keys: [
{ name: 'model', weight: 2 },
{ name: 'reference', weight: 2 },
{ name: 'series', weight: 1 },
'specifications.movement',
'specifications.caseMaterial',
'specifications.caseSize'
],
threshold: 0.3,
includeScore: true
});
}, [watches]);
// Apply filters and sorting
const filteredWatches = useMemo(() => {
let result = watches;
// Fuzzy search
if (filters.searchTerm) {
const searchResults = fuse.search(filters.searchTerm);
result = searchResults.map(r => r.item);
}
// Series filter
if (filters.series.length > 0) {
result = result.filter(w => filters.series.includes(w.series));
}
// Movement type filter
if (filters.movementTypes.length > 0) {
result = result.filter(w => {
const movement = w.specifications?.movement || '';
return filters.movementTypes.some(type => movement.includes(type));
});
}
// Price range filter
result = result.filter(w => {
const price = w.priceHistory[w.priceHistory.length - 1]?.price || 0;
return price >= filters.priceRange[0] && price <= filters.priceRange[1];
});
// Year range filter
result = result.filter(w => {
return w.yearIntroduced >= filters.yearRange[0] && w.yearIntroduced <= filters.yearRange[1];
});
// Favorites filter
if (showFavoritesOnly) {
result = result.filter(w => favorites.includes(w.id));
}
// Sorting
result = [...result].sort((a, b) => {
const calcAppreciation = (watch) => {
if (watch.priceHistory.length < 2) return 0;
const first = watch.priceHistory[0].price;
const last = watch.priceHistory[watch.priceHistory.length - 1].price;
return ((last - first) / first) * 100;
};
switch (filters.sortBy) {
case 'appreciation-desc':
return calcAppreciation(b) - calcAppreciation(a);
case 'appreciation-asc':
return calcAppreciation(a) - calcAppreciation(b);
case 'price-desc':
return (b.priceHistory[b.priceHistory.length - 1]?.price || 0) -
(a.priceHistory[a.priceHistory.length - 1]?.price || 0);
case 'price-asc':
return (a.priceHistory[a.priceHistory.length - 1]?.price || 0) -
(b.priceHistory[b.priceHistory.length - 1]?.price || 0);
case 'year-desc':
return b.yearIntroduced - a.yearIntroduced;
case 'year-asc':
return a.yearIntroduced - b.yearIntroduced;
case 'name-asc':
return a.model.localeCompare(b.model);
case 'name-desc':
return b.model.localeCompare(a.model);
default:
return 0;
}
});
return result;
}, [watches, filters, showFavoritesOnly, favorites, fuse]);
if (loading) {
return <WatchGridSkeleton count={6} />;
}
return (
<div className="space-y-6">
{/* Toolbar */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center space-x-4">
<h2 className="text-2xl font-bold text-gray-800 dark:text-white">
{filteredWatches.length} {filteredWatches.length === 1 ? 'Watch' : 'Watches'}
</h2>
<button
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
className={`px-4 py-2 rounded-lg font-semibold transition-all ${
showFavoritesOnly
? 'bg-omega-red text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
}`}
>
{showFavoritesOnly ? 'Show All' : `Favorites (${favorites.length})`}
</button>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'grid'
? 'bg-omega-red text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
<FiGrid className="text-xl" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-lg transition-colors ${
viewMode === 'list'
? 'bg-omega-red text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300'
}`}
>
<FiList className="text-xl" />
</button>
</div>
</div>
{/* Filters */}
<AdvancedFilters watches={watches} onFilterChange={setFilters} />
{/* Watch Grid/List */}
<AnimatePresence mode="wait">
{filteredWatches.length === 0 ? (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-center py-12"
>
<p className="text-gray-500 dark:text-gray-400 text-lg">
No watches found matching your criteria
</p>
</motion.div>
) : viewMode === 'grid' ? (
<motion.div
key="grid"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
>
{filteredWatches.map((watch, index) => (
<WatchCard
key={watch.id}
watch={watch}
index={index}
onSelect={onWatchSelect}
isFavorite={isFavorite(watch.id)}
onToggleFavorite={toggleFavorite}
/>
))}
</motion.div>
) : (
<motion.div
key="list"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="space-y-4"
>
{filteredWatches.map((watch, index) => (
<WatchListItem
key={watch.id}
watch={watch}
index={index}
onSelect={onWatchSelect}
isFavorite={isFavorite(watch.id)}
onToggleFavorite={toggleFavorite}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function WatchCard({ watch, index, onSelect, isFavorite, onToggleFavorite }) {
const [imageLoaded, setImageLoaded] = useState(false);
const currentPrice = watch.priceHistory[watch.priceHistory.length - 1].price;
const originalPrice = watch.priceHistory[0].price;
const appreciation = (((currentPrice - originalPrice) / originalPrice) * 100).toFixed(2);
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ delay: index * 0.05 }}
onClick={() => onSelect(watch)}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-xl transition-all cursor-pointer overflow-hidden group"
>
{/* Watch Image */}
{watch.imageUrl && (
<div className="relative h-64 overflow-hidden bg-gray-100 dark:bg-gray-700">
{!imageLoaded && (
<div className="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse" />
)}
<img
src={watch.imageUrl}
alt={watch.model}
className={`w-full h-full object-cover group-hover:scale-110 transition-transform duration-500 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setImageLoaded(true)}
onError={(e) => {
e.target.src = 'https://images.unsplash.com/photo-1523170335258-f5ed11844a49?w=800';
setImageLoaded(true);
}}
/>
<div className="absolute top-2 right-2 bg-black bg-opacity-70 text-white px-3 py-1 rounded-full text-xs font-semibold">
{watch.yearIntroduced}
</div>
<div className="absolute top-2 left-2 flex space-x-2">
<FavoriteButton
watchId={watch.id}
isFavorite={isFavorite}
onToggle={onToggleFavorite}
/>
<ShareButton watch={watch} />
</div>
</div>
)}
<div className="gradient-omega text-white p-4">
<h3 className="font-bold text-lg mb-1 truncate">{watch.model}</h3>
<p className="text-sm opacity-90">{watch.series}</p>
</div>
<div className="p-4">
{/* Specifications */}
{watch.specifications && (
<div className="mb-4 bg-gray-50 dark:bg-gray-700 p-3 rounded-lg">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-gray-500 dark:text-gray-400">Case:</span>
<span className="ml-1 font-semibold dark:text-white">{watch.specifications.caseSize}</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Movement:</span>
<span className="ml-1 font-semibold text-omega-red">
{watch.specifications.movement?.split(' ')[1] || 'Auto'}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">Material:</span>
<span className="ml-1 font-semibold dark:text-white">
{watch.specifications.caseMaterial?.split(' ')[0]}
</span>
</div>
<div>
<span className="text-gray-500 dark:text-gray-400">WR:</span>
<span className="ml-1 font-semibold dark:text-white">{watch.specifications.waterResistance}</span>
</div>
</div>
</div>
)}
<div className="mb-4">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Reference</p>
<p className="font-mono text-sm font-semibold dark:text-white">{watch.reference}</p>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Original Price</p>
<p className="font-semibold dark:text-white">${originalPrice.toLocaleString()}</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Current Price</p>
<p className="font-semibold text-omega-red">
${currentPrice.toLocaleString()}
</p>
</div>
</div>
<div className="pt-4 border-t dark:border-gray-700">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600 dark:text-gray-400">Appreciation</span>
<div className="flex items-center space-x-2">
{parseFloat(appreciation) > 0 ? (
<FiTrendingUp className="text-green-600" />
) : (
<FiTrendingDown className="text-red-600" />
)}
<span className={`font-bold text-lg ${
parseFloat(appreciation) > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{appreciation > 0 ? '+' : ''}{appreciation}%
</span>
</div>
</div>
</div>
<button className="w-full mt-4 bg-omega-red hover:bg-red-700 text-white font-semibold py-2 rounded-lg transition-colors">
View Full Details →
</button>
</div>
</motion.div>
);
}
function WatchListItem({ watch, index, onSelect, isFavorite, onToggleFavorite }) {
const currentPrice = watch.priceHistory[watch.priceHistory.length - 1].price;
const originalPrice = watch.priceHistory[0].price;
const appreciation = (((currentPrice - originalPrice) / originalPrice) * 100).toFixed(2);
return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ delay: index * 0.03 }}
onClick={() => onSelect(watch)}
className="bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-xl transition-all cursor-pointer p-4"
>
<div className="flex items-center space-x-4">
{watch.imageUrl && (
<img
src={watch.imageUrl}
alt={watch.model}
className="w-24 h-24 object-cover rounded-lg"
onError={(e) => {
e.target.src = 'https://images.unsplash.com/photo-1523170335258-f5ed11844a49?w=800';
}}
/>
)}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg text-gray-800 dark:text-white truncate">{watch.model}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{watch.series} • {watch.reference}</p>
{watch.specifications && (
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{watch.specifications.caseSize} • {watch.specifications.movement?.split(' ')[1]}
</p>
)}
</div>
<div className="flex items-center space-x-6">
<div className="text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">Original</p>
<p className="font-semibold dark:text-white">${originalPrice.toLocaleString()}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">Current</p>
<p className="font-semibold text-omega-red">${currentPrice.toLocaleString()}</p>
</div>
<div className="text-right min-w-[100px]">
<p className="text-xs text-gray-500 dark:text-gray-400">Appreciation</p>
<p className={`font-bold text-lg ${
parseFloat(appreciation) > 0 ? 'text-green-600' : 'text-red-600'
}`}>
{appreciation > 0 ? '+' : ''}{appreciation}%
</p>
</div>
<div className="flex space-x-2">
<FavoriteButton
watchId={watch.id}
isFavorite={isFavorite}
onToggle={onToggleFavorite}
/>
<ShareButton watch={watch} />
<ExportButton watch={watch} format="csv" />
</div>
</div>
</div>
</motion.div>
);
}
export default EnhancedWatchList;