← back to Watches
src/components/PriceChart.jsx
486 lines
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { Chart, registerables } from 'chart.js';
import { motion, AnimatePresence } from 'framer-motion';
import { getAllMarketData, refreshMarketData } from '../services/marketDataService';
import useAutoRefresh from '../hooks/useAutoRefresh';
import MarketPrice from './MarketPrice';
import RecentSales from './RecentSales';
import TrendIndicator from './TrendIndicator';
import FreshnessIndicator from './FreshnessIndicator';
import RefreshButton from './RefreshButton';
import RetailVsMarket from './RetailVsMarket';
Chart.register(...registerables);
function PriceChart({ watch, onBack }) {
const chartRef = useRef(null);
const chartInstance = useRef(null);
const [marketData, setMarketData] = useState(null);
const [marketDataCollapsed, setMarketDataCollapsed] = useState(false);
// Fetch market data
const fetchMarketData = useCallback(async () => {
if (!watch?.id) return;
try {
const data = getAllMarketData(watch.id);
setMarketData(data);
} catch (error) {
console.error('Failed to fetch market data:', error);
}
}, [watch?.id]);
// Auto-refresh hook (5 minutes)
const { isRefreshing, lastRefresh, refresh } = useAutoRefresh(fetchMarketData, 5 * 60 * 1000);
// Manual refresh handler
const handleManualRefresh = useCallback(async () => {
if (!watch?.id) return;
const data = refreshMarketData(watch.id);
setMarketData(data);
}, [watch?.id]);
// Fetch market data on mount
useEffect(() => {
fetchMarketData();
}, [fetchMarketData]);
useEffect(() => {
if (chartRef.current) {
const ctx = chartRef.current.getContext('2d');
// Destroy previous chart instance
if (chartInstance.current) {
chartInstance.current.destroy();
}
// Prepare data
const years = watch.priceHistory.map(p => p.year);
const prices = watch.priceHistory.map(p => p.price);
// Create new chart
chartInstance.current = new Chart(ctx, {
type: 'line',
data: {
labels: years,
datasets: [{
label: 'Price (USD)',
data: prices,
borderColor: '#C8102E',
backgroundColor: 'rgba(200, 16, 46, 0.1)',
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 5,
pointHoverRadius: 8,
pointBackgroundColor: '#C8102E',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
font: {
size: 14,
weight: 'bold'
}
}
},
tooltip: {
backgroundColor: '#1A1A2E',
titleFont: {
size: 14
},
bodyFont: {
size: 13
},
padding: 12,
callbacks: {
label: function(context) {
return `Price: $${context.parsed.y.toLocaleString()}`;
}
}
}
},
scales: {
y: {
beginAtZero: false,
ticks: {
callback: function(value) {
return '$' + value.toLocaleString();
},
font: {
size: 12
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
ticks: {
font: {
size: 12
}
},
grid: {
display: false
}
}
}
}
});
}
return () => {
if (chartInstance.current) {
chartInstance.current.destroy();
}
};
}, [watch]);
const calculateStats = () => {
const prices = watch.priceHistory.map(p => p.price);
const originalPrice = prices[0];
const currentPrice = prices[prices.length - 1];
const totalAppreciation = ((currentPrice - originalPrice) / originalPrice * 100).toFixed(2);
const yearlyAppreciation = (totalAppreciation / (watch.priceHistory[watch.priceHistory.length - 1].year - watch.priceHistory[0].year)).toFixed(2);
const maxPrice = Math.max(...prices);
const minPrice = Math.min(...prices);
return {
originalPrice,
currentPrice,
totalAppreciation,
yearlyAppreciation,
maxPrice,
minPrice,
totalGain: currentPrice - originalPrice
};
};
const stats = calculateStats();
return (
<div className="space-y-6">
<button
onClick={onBack}
className="flex items-center text-omega-red hover:text-red-700 font-semibold transition-colors"
>
← Back to Watch List
</button>
{/* Watch Info */}
<div className="bg-white rounded-lg shadow-md p-6">
{/* Watch Image */}
{watch.imageUrl && (
<div className="mb-6 relative h-96 overflow-hidden rounded-lg">
<img
src={watch.imageUrl}
alt={watch.model}
className="w-full h-full object-cover"
onError={(e) => {
e.target.src = 'https://images.unsplash.com/photo-1523170335258-f5ed11844a49?w=800';
}}
/>
</div>
)}
<div className="gradient-omega text-white rounded-lg p-6 mb-6">
<h2 className="text-3xl font-bold mb-2">{watch.model}</h2>
<p className="text-lg opacity-90">{watch.series} Collection</p>
<p className="text-sm opacity-75 mt-2">Reference: {watch.reference}</p>
<p className="text-sm opacity-75">Year Introduced: {watch.yearIntroduced}</p>
</div>
<p className="text-gray-700 mb-6">{watch.description}</p>
{/* Detailed Description */}
{watch.detailedDescription && (
<div className="mb-6 p-4 bg-blue-50 border-l-4 border-blue-500 rounded">
<p className="text-gray-800 leading-relaxed">{watch.detailedDescription}</p>
</div>
)}
{/* Technical Specifications */}
{watch.specifications && (
<div className="mt-6 border-t pt-6">
<h3 className="text-xl font-bold text-gray-800 mb-4">Technical Specifications</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Case & Dimensions */}
<div>
<h4 className="font-semibold text-gray-700 mb-3 flex items-center">
<span className="mr-2">⚙️</span> Case & Dimensions
</h4>
<div className="space-y-2 bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span className="text-gray-600">Case Size:</span>
<span className="font-semibold">{watch.specifications.caseSize}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Thickness:</span>
<span className="font-semibold">{watch.specifications.thickness}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Lug Width:</span>
<span className="font-semibold">{watch.specifications.lugWidth}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Weight:</span>
<span className="font-semibold">{watch.specifications.weight}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Material:</span>
<span className="font-semibold">{watch.specifications.caseMaterial}</span>
</div>
</div>
</div>
{/* Movement & Performance */}
<div>
<h4 className="font-semibold text-gray-700 mb-3 flex items-center">
<span className="mr-2">🔧</span> Movement & Performance
</h4>
<div className="space-y-2 bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span className="text-gray-600">Movement:</span>
<span className="font-semibold text-omega-red">{watch.specifications.movement}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Power Reserve:</span>
<span className="font-semibold">{watch.specifications.powerReserve}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Water Resistance:</span>
<span className="font-semibold">{watch.specifications.waterResistance}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Crystal:</span>
<span className="font-semibold">{watch.specifications.crystal}</span>
</div>
</div>
</div>
{/* Design Elements */}
<div>
<h4 className="font-semibold text-gray-700 mb-3 flex items-center">
<span className="mr-2">🎨</span> Design Elements
</h4>
<div className="space-y-2 bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between">
<span className="text-gray-600">Dial Color:</span>
<span className="font-semibold">{watch.specifications.dialColor}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Bezel:</span>
<span className="font-semibold">{watch.specifications.bezel}</span>
</div>
</div>
</div>
{/* Features */}
<div>
<h4 className="font-semibold text-gray-700 mb-3 flex items-center">
<span className="mr-2">⭐</span> Key Features
</h4>
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex flex-wrap gap-2">
{watch.specifications.features.map((feature, idx) => (
<span
key={idx}
className="inline-block bg-omega-red text-white px-3 py-1 rounded-full text-xs font-semibold"
>
{feature}
</span>
))}
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* Statistics Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg shadow-md p-4">
<p className="text-sm text-gray-500 mb-1">Original Price</p>
<p className="text-2xl font-bold text-gray-800">
${stats.originalPrice.toLocaleString()}
</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<p className="text-sm text-gray-500 mb-1">Current Price</p>
<p className="text-2xl font-bold text-omega-red">
${stats.currentPrice.toLocaleString()}
</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<p className="text-sm text-gray-500 mb-1">Total Appreciation</p>
<p className="text-2xl font-bold text-green-600">
+{stats.totalAppreciation}%
</p>
</div>
<div className="bg-white rounded-lg shadow-md p-4">
<p className="text-sm text-gray-500 mb-1">Total Gain</p>
<p className="text-2xl font-bold text-green-600">
+${stats.totalGain.toLocaleString()}
</p>
</div>
</div>
{/* Market Data Section */}
<motion.div
className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg shadow-xl overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
{/* Header - always visible */}
<div
className="flex items-center justify-between p-4 cursor-pointer md:cursor-default border-b border-gray-700/50"
onClick={() => setMarketDataCollapsed(!marketDataCollapsed)}
>
<div className="flex items-center gap-3">
<h3 className="text-xl font-bold text-white">Live Market Data</h3>
{marketData && (
<FreshnessIndicator timestamp={marketData.timestamp} compact />
)}
{isRefreshing && (
<span className="text-xs text-amber-400 animate-pulse">Refreshing...</span>
)}
</div>
<div className="flex items-center gap-2">
<RefreshButton onRefresh={handleManualRefresh} size="sm" />
{/* Mobile collapse toggle */}
<button className="md:hidden text-gray-400 hover:text-white transition-colors">
<svg
className={`w-5 h-5 transform transition-transform ${marketDataCollapsed ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
{/* Content - collapsible on mobile */}
<AnimatePresence>
{(!marketDataCollapsed || window.innerWidth >= 768) && marketData && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3 }}
className="p-4 md:p-6"
>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
{/* Retail vs Market Comparison */}
<RetailVsMarket
retailMSRP={marketData.retailMSRP}
marketPrice={marketData.marketPrice}
retailHistory={marketData.retailHistory}
reference={marketData.reference}
/>
{/* Market Price & Trends */}
<div className="space-y-4">
<MarketPrice priceData={marketData.marketPrice} />
<TrendIndicator
trends={marketData.trends}
defaultPeriod="30d"
showPeriodSelector={true}
/>
</div>
</div>
{/* Recent Sales with Source Links */}
<div>
<RecentSales sales={marketData.recentSales} limit={5} />
</div>
{/* Freshness indicator (full) */}
<div className="mt-4 pt-4 border-t border-gray-700/50">
<FreshnessIndicator timestamp={marketData.timestamp} />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Fallback when no data */}
{!marketData && (
<div className="p-6 text-center text-gray-400">
<p>Loading market data...</p>
</div>
)}
</motion.div>
{/* Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-xl font-bold text-gray-800 mb-4">Price History Chart</h3>
<div className="h-96">
<canvas ref={chartRef}></canvas>
</div>
</div>
{/* Price History Table */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-xl font-bold text-gray-800 mb-4">Detailed Price History</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-300">
<th className="text-left py-3 px-4">Year</th>
<th className="text-right py-3 px-4">Price</th>
<th className="text-right py-3 px-4">Change</th>
<th className="text-right py-3 px-4">% Change</th>
<th className="text-left py-3 px-4">Condition</th>
</tr>
</thead>
<tbody>
{watch.priceHistory.map((entry, index) => {
const prevPrice = index > 0 ? watch.priceHistory[index - 1].price : entry.price;
const change = entry.price - prevPrice;
const percentChange = prevPrice > 0 ? ((change / prevPrice) * 100).toFixed(2) : 0;
return (
<tr key={index} className="border-b hover:bg-gray-50">
<td className="py-3 px-4 font-semibold">{entry.year}</td>
<td className="py-3 px-4 text-right font-semibold">
${entry.price.toLocaleString()}
</td>
<td className={`py-3 px-4 text-right font-semibold ${
change > 0 ? 'text-green-600' : change < 0 ? 'text-red-600' : 'text-gray-600'
}`}>
{change > 0 ? '+' : ''}{change === 0 ? '-' : `$${change.toLocaleString()}`}
</td>
<td className={`py-3 px-4 text-right font-semibold ${
percentChange > 0 ? 'text-green-600' : percentChange < 0 ? 'text-red-600' : 'text-gray-600'
}`}>
{percentChange > 0 ? '+' : ''}{percentChange === 0 ? '-' : `${percentChange}%`}
</td>
<td className="py-3 px-4">
<span className="inline-block bg-gray-200 px-2 py-1 rounded text-xs font-semibold">
{entry.condition}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default PriceChart;