← back to Handbag Auth Nextjs
src/components/PriceHistoryChart.tsx
340 lines
'use client'
import { LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { useState, useEffect } from 'react'
interface PriceHistoryChartProps {
externalId?: string
listingId?: string
brand?: string
model?: string
}
export function PriceHistoryChart({ externalId, listingId, brand, model }: PriceHistoryChartProps) {
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [chartType, setChartType] = useState<'line' | 'area'>('area')
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('all')
useEffect(() => {
fetchHistory()
}, [externalId, listingId])
const fetchHistory = async () => {
try {
const params = new URLSearchParams()
if (externalId) params.append('externalId', externalId)
if (listingId) params.append('listingId', listingId)
params.append('limit', '1000')
const res = await fetch(`/api/price-history?${params}`)
const data = await res.json()
if (data.success) {
setHistory(data.history)
}
} catch (error) {
console.error('Failed to fetch price history:', error)
} finally {
setLoading(false)
}
}
const filterByTimeRange = (data: any[]) => {
if (timeRange === 'all') return data
const now = new Date()
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90
const cutoffDate = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
return data.filter(item => new Date(item.recordedAt) >= cutoffDate)
}
const exportToCSV = () => {
const csv = [
['Date', 'Price USD', 'Price JPY', 'Available', 'Condition', 'Type'],
...history.map(h => [
h.recordedAt,
h.priceUsd || '',
h.priceJpy,
h.isAvailable ? 'Yes' : 'No',
h.condition || '',
h.listingType || ''
])
].map(row => row.join(',')).join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `price-history-${externalId || listingId}-${new Date().toISOString().split('T')[0]}.csv`
a.click()
window.URL.revokeObjectURL(url)
}
const exportToJSON = () => {
const json = JSON.stringify(history, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `price-history-${externalId || listingId}-${new Date().toISOString().split('T')[0]}.json`
a.click()
window.URL.revokeObjectURL(url)
}
if (loading) {
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<div className="animate-pulse">
<div className="h-8 bg-gray-300 rounded w-1/3 mb-4"></div>
<div className="h-64 bg-gray-300 rounded"></div>
</div>
</div>
)
}
if (history.length === 0) {
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Price History</h3>
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="mt-2 text-sm text-gray-500">No price history data available yet</p>
</div>
</div>
)
}
// Filter data by time range
const filteredHistory = filterByTimeRange(history)
// Calculate stats
const prices = filteredHistory.map(h => h.priceUsd).filter(p => p)
const currentPrice = prices[prices.length - 1]
const originalPrice = prices[0]
const highestPrice = Math.max(...prices)
const lowestPrice = Math.min(...prices)
const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length
const priceChange = currentPrice - originalPrice
const priceChangePercent = ((priceChange / originalPrice) * 100).toFixed(2)
// Format data for chart
const chartData = filteredHistory.map(item => ({
date: new Date(item.recordedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
fullDate: item.recordedAt,
price: item.priceUsd || 0,
priceJpy: item.priceJpy,
available: item.isAvailable === 1,
}))
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload
return (
<div className="bg-white p-4 rounded-lg shadow-xl border border-gray-200">
<p className="text-sm text-gray-600 mb-1">{data.fullDate}</p>
<p className="text-lg font-bold text-gray-900">${data.price.toLocaleString()}</p>
<p className="text-sm text-gray-500">¥{data.priceJpy.toLocaleString()}</p>
<p className={`text-xs mt-1 ${data.available ? 'text-green-600' : 'text-red-600'}`}>
{data.available ? '✓ Available' : '✗ Sold/Unavailable'}
</p>
</div>
)
}
return null
}
return (
<div className="bg-white rounded-xl shadow-lg p-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6 gap-4">
<div>
<h3 className="text-2xl font-bold text-gray-900">Price History</h3>
{brand && model && (
<p className="text-sm text-gray-600 mt-1">{brand} {model}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Time Range Filter */}
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
{(['7d', '30d', '90d', 'all'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
timeRange === range
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{range === 'all' ? 'All Time' : range.toUpperCase()}
</button>
))}
</div>
{/* Chart Type Toggle */}
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
<button
onClick={() => setChartType('line')}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
chartType === 'line'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Line
</button>
<button
onClick={() => setChartType('area')}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
chartType === 'area'
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
Area
</button>
</div>
{/* Export Buttons */}
<div className="relative group">
<button className="px-3 py-1 bg-gray-100 text-gray-700 rounded-lg text-xs font-medium hover:bg-gray-200 transition-colors flex items-center space-x-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>Export</span>
</button>
<div className="absolute right-0 mt-2 w-32 bg-white rounded-lg shadow-xl border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all z-10">
<button
onClick={exportToCSV}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 rounded-t-lg"
>
CSV
</button>
<button
onClick={exportToJSON}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 rounded-b-lg"
>
JSON
</button>
</div>
</div>
</div>
</div>
{/* Price Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg p-4">
<p className="text-xs text-blue-600 font-medium uppercase tracking-wide">Current</p>
<p className="text-2xl font-bold text-blue-900 mt-1">${currentPrice?.toLocaleString()}</p>
<p className={`text-sm mt-1 ${priceChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{priceChange >= 0 ? '+' : ''}{priceChangePercent}%
</p>
</div>
<div className="bg-gradient-to-br from-green-50 to-green-100 rounded-lg p-4">
<p className="text-xs text-green-600 font-medium uppercase tracking-wide">Lowest</p>
<p className="text-2xl font-bold text-green-900 mt-1">${lowestPrice.toLocaleString()}</p>
<p className="text-xs text-green-600 mt-1">Best deal</p>
</div>
<div className="bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg p-4">
<p className="text-xs text-orange-600 font-medium uppercase tracking-wide">Average</p>
<p className="text-2xl font-bold text-orange-900 mt-1">${Math.round(avgPrice).toLocaleString()}</p>
<p className="text-xs text-orange-600 mt-1">Mean price</p>
</div>
<div className="bg-gradient-to-br from-red-50 to-red-100 rounded-lg p-4">
<p className="text-xs text-red-600 font-medium uppercase tracking-wide">Highest</p>
<p className="text-2xl font-bold text-red-900 mt-1">${highestPrice.toLocaleString()}</p>
<p className="text-xs text-red-600 mt-1">Peak price</p>
</div>
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg p-4">
<p className="text-xs text-purple-600 font-medium uppercase tracking-wide">Data Points</p>
<p className="text-2xl font-bold text-purple-900 mt-1">{filteredHistory.length}</p>
<p className="text-xs text-purple-600 mt-1">Tracked prices</p>
</div>
</div>
{/* Chart */}
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
{chartType === 'area' ? (
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorPrice" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
stroke="#6b7280"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="#6b7280"
style={{ fontSize: '12px' }}
tickFormatter={(value) => `$${value.toLocaleString()}`}
/>
<Tooltip content={<CustomTooltip />} />
<Area
type="monotone"
dataKey="price"
stroke="#8b5cf6"
strokeWidth={3}
fill="url(#colorPrice)"
/>
</AreaChart>
) : (
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey="date"
stroke="#6b7280"
style={{ fontSize: '12px' }}
/>
<YAxis
stroke="#6b7280"
style={{ fontSize: '12px' }}
tickFormatter={(value) => `$${value.toLocaleString()}`}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="monotone"
dataKey="price"
stroke="#8b5cf6"
strokeWidth={3}
dot={{ fill: '#8b5cf6', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
)}
</ResponsiveContainer>
</div>
{/* Legend */}
<div className="mt-4 flex items-center justify-center space-x-6 text-sm text-gray-600">
<div className="flex items-center">
<div className="w-3 h-3 bg-purple-600 rounded-full mr-2"></div>
<span>Price USD</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span>Available</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
<span>Sold</span>
</div>
</div>
</div>
)
}