← back to Watches

src/components/MobileEnhancements.jsx

526 lines

/**
 * MOBILE-FIRST ENHANCEMENTS
 * Premium mobile experience for the Omega Watch platform
 *
 * Features:
 * - Bottom navigation for mobile
 * - Swipe gestures
 * - Pull-to-refresh
 * - Touch-optimized interactions
 * - Mobile filter drawer
 * - Responsive touch targets
 */

import React, { useState, useRef, useEffect } from 'react';
import { motion, useAnimation, PanInfo } from 'framer-motion';
import { FiHome, FiList, FiLayers, FiFilter, FiX, FiChevronDown } from 'react-icons/fi';

// ============================================================================
// MOBILE BOTTOM NAVIGATION
// ============================================================================
export const MobileBottomNav = ({ activeView, onViewChange }) => {
  const navItems = [
    { id: 'dashboard', label: 'Dashboard', icon: FiHome },
    { id: 'list', label: 'Watches', icon: FiList },
    { id: 'compare', label: 'Compare', icon: FiLayers },
  ];

  return (
    <motion.nav
      initial={{ y: 100 }}
      animate={{ y: 0 }}
      className="
        fixed bottom-0 left-0 right-0 z-40
        bg-white dark:bg-gray-800
        border-t-2 border-gray-200 dark:border-gray-700
        shadow-2xl
        md:hidden
      "
    >
      <div className="flex items-center justify-around h-16 px-2">
        {navItems.map((item) => {
          const Icon = item.icon;
          const isActive = activeView === item.id;

          return (
            <button
              key={item.id}
              onClick={() => onViewChange(item.id)}
              className={`
                flex flex-col items-center justify-center
                w-full h-full relative
                transition-colors duration-200
                ${isActive ? 'text-omega-red' : 'text-gray-600 dark:text-gray-400'}
              `}
              aria-label={item.label}
              aria-current={isActive ? 'page' : undefined}
            >
              {/* Active indicator */}
              {isActive && (
                <motion.div
                  layoutId="activeTab"
                  className="absolute top-0 left-1/2 -translate-x-1/2 w-12 h-1 bg-omega-red rounded-full"
                  transition={{ type: 'spring', stiffness: 500, damping: 30 }}
                />
              )}

              <motion.div
                animate={{ scale: isActive ? 1.1 : 1 }}
                transition={{ type: 'spring', stiffness: 400, damping: 17 }}
              >
                <Icon className="text-2xl mb-1" />
              </motion.div>

              <span className={`text-xs font-medium ${isActive ? 'font-bold' : ''}`}>
                {item.label}
              </span>

              {/* Badge (optional) */}
              {item.badge && (
                <span className="absolute top-2 right-1/4 bg-omega-red text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
                  {item.badge}
                </span>
              )}
            </button>
          );
        })}
      </div>

      {/* Safe area spacer for iOS */}
      <div className="h-safe-bottom bg-white dark:bg-gray-800" />
    </motion.nav>
  );
};

// ============================================================================
// MOBILE FILTER DRAWER
// ============================================================================
export const MobileFilterDrawer = ({ isOpen, onClose, children }) => {
  const controls = useAnimation();
  const constraintsRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      controls.start({ y: 0 });
      document.body.style.overflow = 'hidden';
    } else {
      controls.start({ y: '100%' });
      document.body.style.overflow = '';
    }
  }, [isOpen, controls]);

  const handleDragEnd = (event, info) => {
    // Close drawer if dragged down more than 150px
    if (info.offset.y > 150) {
      onClose();
    } else {
      controls.start({ y: 0 });
    }
  };

  if (!isOpen) return null;

  return (
    <>
      {/* Backdrop */}
      <motion.div
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        onClick={onClose}
        className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[1040] md:hidden"
      />

      {/* Drawer */}
      <motion.div
        ref={constraintsRef}
        initial={{ y: '100%' }}
        animate={controls}
        drag="y"
        dragConstraints={{ top: 0, bottom: 0 }}
        dragElastic={{ top: 0, bottom: 0.5 }}
        onDragEnd={handleDragEnd}
        className="
          fixed bottom-0 left-0 right-0 z-[1050]
          bg-white dark:bg-gray-800
          rounded-t-3xl shadow-2xl
          max-h-[85vh] overflow-hidden
          md:hidden
        "
      >
        {/* Drag handle */}
        <div className="flex items-center justify-center py-4 cursor-grab active:cursor-grabbing">
          <div className="w-12 h-1.5 bg-gray-300 dark:bg-gray-600 rounded-full" />
        </div>

        {/* Header */}
        <div className="flex items-center justify-between px-6 pb-4 border-b dark:border-gray-700">
          <div className="flex items-center space-x-3">
            <FiFilter className="text-omega-red text-xl" />
            <h2 className="text-xl font-bold text-gray-800 dark:text-white">
              Filters
            </h2>
          </div>
          <button
            onClick={onClose}
            className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
            aria-label="Close filters"
          >
            <FiX className="text-2xl" />
          </button>
        </div>

        {/* Content */}
        <div className="overflow-y-auto max-h-[calc(85vh-8rem)] p-6">
          {children}
        </div>

        {/* Footer with actions */}
        <div className="sticky bottom-0 p-4 bg-white dark:bg-gray-800 border-t dark:border-gray-700">
          <div className="flex space-x-3">
            <button
              onClick={onClose}
              className="flex-1 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white font-semibold rounded-xl hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
            >
              Cancel
            </button>
            <button
              onClick={onClose}
              className="flex-1 px-6 py-3 bg-gradient-to-r from-omega-red to-red-700 text-white font-semibold rounded-xl hover:shadow-lg transition-all"
            >
              Apply Filters
            </button>
          </div>
        </div>
      </motion.div>
    </>
  );
};

// ============================================================================
// SWIPEABLE CARD
// ============================================================================
export const SwipeableCard = ({
  children,
  onSwipeLeft,
  onSwipeRight,
  threshold = 100,
  className = '',
}) => {
  const [exitX, setExitX] = useState(0);

  const handleDragEnd = (event, info) => {
    const { offset } = info;

    if (offset.x > threshold && onSwipeRight) {
      setExitX(1000);
      setTimeout(() => onSwipeRight(), 200);
    } else if (offset.x < -threshold && onSwipeLeft) {
      setExitX(-1000);
      setTimeout(() => onSwipeLeft(), 200);
    }
  };

  return (
    <motion.div
      drag="x"
      dragConstraints={{ left: 0, right: 0 }}
      dragElastic={0.7}
      onDragEnd={handleDragEnd}
      animate={{ x: exitX }}
      transition={{ type: 'spring', stiffness: 300, damping: 30 }}
      className={`touch-pan-y ${className}`}
    >
      {children}
    </motion.div>
  );
};

// ============================================================================
// PULL TO REFRESH
// ============================================================================
export const PullToRefresh = ({ onRefresh, children }) => {
  const [isPulling, setIsPulling] = useState(false);
  const [pullDistance, setPullDistance] = useState(0);
  const [isRefreshing, setIsRefreshing] = useState(false);
  const threshold = 80;

  const handleDrag = (event, info) => {
    const { offset } = info;
    if (offset.y > 0 && window.scrollY === 0) {
      setIsPulling(true);
      setPullDistance(Math.min(offset.y, threshold * 1.5));
    }
  };

  const handleDragEnd = async (event, info) => {
    const { offset } = info;

    if (offset.y >= threshold && window.scrollY === 0) {
      setIsRefreshing(true);
      await onRefresh();
      setIsRefreshing(false);
    }

    setIsPulling(false);
    setPullDistance(0);
  };

  const pullProgress = Math.min((pullDistance / threshold) * 100, 100);

  return (
    <div className="relative">
      {/* Pull indicator */}
      <motion.div
        className="absolute top-0 left-0 right-0 flex items-center justify-center overflow-hidden z-50"
        style={{ height: pullDistance }}
        animate={{ opacity: isPulling || isRefreshing ? 1 : 0 }}
      >
        <div className="flex flex-col items-center space-y-2">
          <motion.div
            animate={{ rotate: isRefreshing ? 360 : pullProgress * 3.6 }}
            transition={isRefreshing ? {
              duration: 1,
              repeat: Infinity,
              ease: 'linear',
            } : {}}
            className="w-8 h-8 border-3 border-omega-red border-t-transparent rounded-full"
          />
          <span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
            {isRefreshing ? 'Refreshing...' : pullDistance >= threshold ? 'Release to refresh' : 'Pull to refresh'}
          </span>
        </div>
      </motion.div>

      {/* Main content */}
      <motion.div
        drag="y"
        dragConstraints={{ top: 0, bottom: 0 }}
        dragElastic={{ top: 0.5, bottom: 0 }}
        onDrag={handleDrag}
        onDragEnd={handleDragEnd}
        style={{ touchAction: 'pan-y' }}
      >
        {children}
      </motion.div>
    </div>
  );
};

// ============================================================================
// TOUCH OPTIMIZED BUTTON
// ============================================================================
export const TouchButton = ({
  children,
  onClick,
  variant = 'primary',
  size = 'md',
  icon,
  fullWidth = false,
  className = '',
}) => {
  const variants = {
    primary: 'bg-gradient-to-r from-omega-red to-red-700 text-white',
    secondary: 'bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-white',
    outline: 'border-2 border-omega-red text-omega-red',
  };

  const sizes = {
    sm: 'min-h-[44px] px-4 text-sm',
    md: 'min-h-[48px] px-6 text-base',
    lg: 'min-h-[52px] px-8 text-lg',
  };

  return (
    <motion.button
      whileTap={{ scale: 0.95 }}
      onClick={onClick}
      className={`
        ${variants[variant]}
        ${sizes[size]}
        ${fullWidth ? 'w-full' : ''}
        font-semibold rounded-xl
        flex items-center justify-center space-x-2
        transition-all duration-200
        active:shadow-inner
        ${className}
      `}
    >
      {icon && <span className="text-xl">{icon}</span>}
      <span>{children}</span>
    </motion.button>
  );
};

// ============================================================================
// HORIZONTAL SCROLL CAROUSEL (Mobile optimized)
// ============================================================================
export const HorizontalScrollCarousel = ({ items, renderItem, className = '' }) => {
  const scrollRef = useRef(null);
  const [canScrollLeft, setCanScrollLeft] = useState(false);
  const [canScrollRight, setCanScrollRight] = useState(true);

  const checkScroll = () => {
    if (!scrollRef.current) return;

    const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
    setCanScrollLeft(scrollLeft > 0);
    setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
  };

  useEffect(() => {
    checkScroll();
    window.addEventListener('resize', checkScroll);
    return () => window.removeEventListener('resize', checkScroll);
  }, []);

  return (
    <div className={`relative ${className}`}>
      {/* Scroll container */}
      <div
        ref={scrollRef}
        onScroll={checkScroll}
        className="
          flex overflow-x-auto space-x-4 pb-4
          snap-x snap-mandatory
          scrollbar-hide
          -mx-4 px-4
        "
        style={{
          scrollbarWidth: 'none',
          msOverflowStyle: 'none',
        }}
      >
        {items.map((item, index) => (
          <div
            key={index}
            className="flex-shrink-0 snap-start"
          >
            {renderItem(item, index)}
          </div>
        ))}
      </div>

      {/* Scroll indicators (desktop only) */}
      <div className="hidden md:block">
        {canScrollLeft && (
          <button
            onClick={() => scrollRef.current?.scrollBy({ left: -300, behavior: 'smooth' })}
            className="absolute left-0 top-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-2 rounded-full shadow-lg"
          >
            <FiChevronDown className="rotate-90" />
          </button>
        )}

        {canScrollRight && (
          <button
            onClick={() => scrollRef.current?.scrollBy({ left: 300, behavior: 'smooth' })}
            className="absolute right-0 top-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-2 rounded-full shadow-lg"
          >
            <FiChevronDown className="-rotate-90" />
          </button>
        )}
      </div>

      {/* Scroll progress indicator */}
      <div className="mt-4 flex justify-center space-x-1 md:hidden">
        {items.map((_, index) => (
          <div
            key={index}
            className="h-1 w-8 rounded-full bg-gray-300 dark:bg-gray-600"
          />
        ))}
      </div>
    </div>
  );
};

// ============================================================================
// MOBILE SEARCH BAR
// ============================================================================
export const MobileSearchBar = ({ value, onChange, onFocus, placeholder = 'Search watches...' }) => {
  const [isFocused, setIsFocused] = useState(false);

  return (
    <motion.div
      animate={{
        scale: isFocused ? 1.02 : 1,
      }}
      className="relative"
    >
      <input
        type="search"
        value={value}
        onChange={onChange}
        onFocus={() => {
          setIsFocused(true);
          onFocus?.();
        }}
        onBlur={() => setIsFocused(false)}
        placeholder={placeholder}
        className="
          w-full h-12 pl-12 pr-4
          bg-gray-100 dark:bg-gray-800
          text-gray-800 dark:text-white
          placeholder-gray-500 dark:placeholder-gray-400
          border-2 border-transparent
          focus:border-omega-red focus:bg-white dark:focus:bg-gray-700
          rounded-full
          outline-none
          transition-all duration-200
          text-base
        "
        style={{ fontSize: '16px' }} // Prevents iOS zoom
      />

      {/* Search icon */}
      <div className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400">
        <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
        </svg>
      </div>

      {/* Clear button */}
      {value && (
        <button
          onClick={() => onChange({ target: { value: '' } })}
          className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
        >
          <FiX className="w-5 h-5" />
        </button>
      )}
    </motion.div>
  );
};

// ============================================================================
// HAPTIC FEEDBACK WRAPPER
// ============================================================================
export const withHaptic = (Component) => {
  return (props) => {
    const triggerHaptic = () => {
      if ('vibrate' in navigator) {
        navigator.vibrate(10);
      }
    };

    const handleClick = (e) => {
      triggerHaptic();
      if (props.onClick) props.onClick(e);
    };

    return <Component {...props} onClick={handleClick} />;
  };
};

export default {
  MobileBottomNav,
  MobileFilterDrawer,
  SwipeableCard,
  PullToRefresh,
  TouchButton,
  HorizontalScrollCarousel,
  MobileSearchBar,
  withHaptic,
};