← back to Watches
src/App.jsx
310 lines
import React, { useState, useEffect, useMemo } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { FiList, FiTrendingUp, FiAward, FiPieChart, FiArrowLeft, FiGrid, FiSearch } from 'react-icons/fi';
import HeroCarousel from './components/HeroCarousel';
import CollapsibleSection from './components/CollapsibleSection';
import WatchCard from './components/WatchCard';
import PriceChart from './components/PriceChart';
import { useDarkMode } from './hooks/useLocalStorage';
import { saveWatchesOffline, getWatchesOffline } from './utils/offlineStorage';
import { getOnlineStatus } from './utils/pwaUtils';
function App() {
const [watches, setWatches] = useState([]);
const [statistics, setStatistics] = useState(null);
const [selectedWatch, setSelectedWatch] = useState(null);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [darkMode] = useDarkMode();
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
if (getOnlineStatus()) {
const [watchesRes, statsRes] = await Promise.all([
fetch('/api/watches'),
fetch('/api/statistics')
]);
const watchesData = await watchesRes.json();
const statsData = await statsRes.json();
const watchesList = watchesData.watches || watchesData;
setWatches(watchesList);
setStatistics(statsData);
await saveWatchesOffline(watchesList);
} else {
const offlineWatches = await getWatchesOffline();
setWatches(offlineWatches);
}
} catch (error) {
console.error('Error loading data:', error);
const offlineWatches = await getWatchesOffline();
if (offlineWatches.length > 0) setWatches(offlineWatches);
} finally {
setLoading(false);
}
};
// Calculate watch stats
const watchesWithStats = useMemo(() => {
return watches.map(watch => {
const first = watch.priceHistory[0]?.price || 1;
const last = watch.priceHistory[watch.priceHistory.length - 1]?.price || first;
const appreciation = ((last - first) / first) * 100;
return { ...watch, appreciation, currentPrice: last };
});
}, [watches]);
// Top performers
const topPerformers = useMemo(() => {
return [...watchesWithStats]
.sort((a, b) => b.appreciation - a.appreciation)
.slice(0, 10);
}, [watchesWithStats]);
// Series breakdown
const seriesBreakdown = useMemo(() => {
const map = {};
watches.forEach(w => {
map[w.series] = (map[w.series] || 0) + 1;
});
return Object.entries(map)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
}, [watches]);
// Filtered watches
const filteredWatches = useMemo(() => {
if (!searchQuery.trim()) return watchesWithStats;
const q = searchQuery.toLowerCase();
return watchesWithStats.filter(w =>
w.model.toLowerCase().includes(q) ||
w.series.toLowerCase().includes(q) ||
w.description?.toLowerCase().includes(q)
);
}, [watchesWithStats, searchQuery]);
const handleWatchSelect = (watch) => {
setSelectedWatch(watch);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => {
setSelectedWatch(null);
};
// Loading State
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-900">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center mx-auto mb-4 animate-pulse shadow-lg shadow-amber-500/30">
<span className="text-3xl">Ω</span>
</div>
<h1 className="text-white text-xl font-bold">OMEGA VAULT</h1>
<p className="text-amber-400 text-sm mt-1">Loading...</p>
</motion.div>
</div>
);
}
// Detail View (when watch is selected)
if (selectedWatch) {
return (
<div className="min-h-screen bg-slate-900">
{/* Back Header */}
<header className="fixed top-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-lg border-b border-slate-700">
<div className="container mx-auto px-4 h-16 flex items-center">
<button
onClick={handleBack}
className="flex items-center gap-2 text-amber-400 hover:text-amber-300 font-medium transition-colors"
>
<FiArrowLeft />
<span>Back to Collection</span>
</button>
</div>
</header>
<main className="pt-20 pb-12">
<div className="container mx-auto px-4">
<PriceChart watch={selectedWatch} onBack={handleBack} />
</div>
</main>
</div>
);
}
// Main Single Page View
return (
<div className="min-h-screen bg-slate-900">
{/* Fixed Header */}
<header className="fixed top-0 left-0 right-0 z-50 bg-slate-900/90 backdrop-blur-lg border-b border-amber-500/20">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center shadow-lg shadow-amber-500/20">
<span className="text-lg font-bold">Ω</span>
</div>
<div className="hidden sm:block">
<h1 className="text-lg font-bold text-white">OMEGA <span className="text-amber-400">VAULT</span></h1>
</div>
</div>
{/* Search */}
<div className="flex-1 max-w-md mx-4">
<div className="relative">
<FiSearch className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search watches..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-amber-500/50"
/>
</div>
</div>
{/* Stats */}
<div className="hidden md:flex items-center gap-4 text-sm">
<span className="text-gray-400">{watches.length} Watches</span>
<span className="text-green-400">Live</span>
</div>
</div>
</header>
{/* Hero Carousel */}
<section className="pt-16">
<HeroCarousel watches={watches} onWatchSelect={handleWatchSelect} />
</section>
{/* Main Content - Collapsible Sections */}
<main className="container mx-auto px-4 py-8 space-y-4">
{/* All Watches Section */}
<CollapsibleSection
title="Complete Collection"
subtitle={`${filteredWatches.length} watches available`}
icon={FiGrid}
badge={searchQuery ? 'Filtered' : null}
defaultOpen={true}
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mt-4">
{filteredWatches.map((watch, index) => (
<WatchCard
key={watch.id}
watch={watch}
index={index}
onSelect={handleWatchSelect}
/>
))}
</div>
{filteredWatches.length === 0 && (
<p className="text-center text-gray-400 py-8">No watches found matching "{searchQuery}"</p>
)}
</CollapsibleSection>
{/* Top Performers */}
<CollapsibleSection
title="Top Performers"
subtitle="Highest appreciation watches"
icon={FiAward}
badge="Top 10"
>
<div className="space-y-3 mt-4">
{topPerformers.map((watch, index) => (
<motion.div
key={watch.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => handleWatchSelect(watch)}
className="flex items-center justify-between p-4 bg-slate-700/30 hover:bg-slate-700/50 rounded-xl cursor-pointer transition-colors group"
>
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-amber-500/20 to-amber-600/20 border border-amber-500/30 flex items-center justify-center text-amber-400 font-bold">
#{index + 1}
</div>
<div>
<h4 className="text-white font-semibold group-hover:text-amber-400 transition-colors">{watch.model}</h4>
<p className="text-gray-400 text-sm">{watch.series}</p>
</div>
</div>
<div className="text-right">
<p className="text-green-400 font-bold">+{watch.appreciation.toFixed(0)}%</p>
<p className="text-gray-400 text-sm">${watch.currentPrice.toLocaleString()}</p>
</div>
</motion.div>
))}
</div>
</CollapsibleSection>
{/* Collection by Series */}
<CollapsibleSection
title="Collection Overview"
subtitle="Watches by series"
icon={FiPieChart}
>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 mt-4">
{seriesBreakdown.map((series, index) => (
<motion.div
key={series.name}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
className="p-4 bg-gradient-to-br from-slate-700/50 to-slate-800/50 border border-slate-600 rounded-xl hover:border-amber-500/50 transition-colors"
>
<h4 className="text-white font-semibold mb-1">{series.name}</h4>
<p className="text-3xl font-bold text-amber-400">{series.count}</p>
<p className="text-gray-400 text-xs">models</p>
</motion.div>
))}
</div>
</CollapsibleSection>
{/* Market Statistics */}
{statistics && (
<CollapsibleSection
title="Market Statistics"
subtitle="Overall market performance"
icon={FiTrendingUp}
>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="p-4 bg-slate-700/30 rounded-xl text-center">
<p className="text-gray-400 text-sm mb-1">Total Watches</p>
<p className="text-2xl font-bold text-white">{statistics.totalWatches}</p>
</div>
<div className="p-4 bg-slate-700/30 rounded-xl text-center">
<p className="text-gray-400 text-sm mb-1">Watch Series</p>
<p className="text-2xl font-bold text-white">{statistics.totalSeries}</p>
</div>
<div className="p-4 bg-slate-700/30 rounded-xl text-center">
<p className="text-gray-400 text-sm mb-1">Avg Appreciation</p>
<p className="text-2xl font-bold text-green-400">+{statistics.averageAppreciation}%</p>
</div>
<div className="p-4 bg-slate-700/30 rounded-xl text-center">
<p className="text-gray-400 text-sm mb-1">Avg Price</p>
<p className="text-2xl font-bold text-amber-400">
${Math.round(watchesWithStats.reduce((sum, w) => sum + w.currentPrice, 0) / watchesWithStats.length).toLocaleString()}
</p>
</div>
</div>
</CollapsibleSection>
)}
</main>
{/* Footer */}
<footer className="border-t border-slate-700 py-8 mt-8">
<div className="container mx-auto px-4 text-center text-gray-400 text-sm">
<p>OMEGA VAULT - Price Intelligence Platform</p>
<p className="mt-1">Tracking {watches.length} iconic timepieces since 1957</p>
</div>
</footer>
</div>
);
}
export default App;