← 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;