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