Some checks failed
CI / Lint (pull_request) Has been cancelled
CI / Schema map up to date (pull_request) Has been cancelled
CI / Forbidden patterns (9 checks) (pull_request) Has been cancelled
CI / Migrations apply (node-pg-migrate) (pull_request) Has been cancelled
Visual diff / Should run? (pull_request) Has been cancelled
CI / Unit tests (vitest) (pull_request) Has been cancelled
Convoy metrics gate / Require role-event telemetry on convoy PRs (pull_request) Has been cancelled
PR Health rollup / Aggregate gate status (pull_request) Has been cancelled
Preview smoke / Should run? (pull_request) Has been cancelled
Preview smoke / Playwright smoke (pull_request) Has been cancelled
Visual diff / Screenshot diff (pull_request) Has been cancelled
Add pricing schema, free API integrations (MTG bulk, TCGdex, Foolio), valuation and sort-by-price, card detail pricing UI, and nearby store discovery via home zip and OSM geo clients. Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
3.9 KiB
JavaScript
110 lines
3.9 KiB
JavaScript
function buildChartGeometry(points, width, height, padding) {
|
|
if (!points.length) return null;
|
|
|
|
const amounts = points.map((point) => point.amount);
|
|
const min = Math.min(...amounts);
|
|
const max = Math.max(...amounts);
|
|
const range = max - min || 1;
|
|
const innerWidth = width - padding * 2;
|
|
const innerHeight = height - padding * 2;
|
|
|
|
const coordinates = points.map((point, index) => {
|
|
const x = padding + (index / Math.max(points.length - 1, 1)) * innerWidth;
|
|
const y = padding + innerHeight - ((point.amount - min) / range) * innerHeight;
|
|
return { x, y };
|
|
});
|
|
|
|
const polyline = coordinates.map(({ x, y }) => `${x},${y}`).join(' ');
|
|
const area = [
|
|
`${coordinates[0].x},${height - padding}`,
|
|
...coordinates.map(({ x, y }) => `${x},${y}`),
|
|
`${coordinates[coordinates.length - 1].x},${height - padding}`,
|
|
].join(' ');
|
|
|
|
return { polyline, area, min, max };
|
|
}
|
|
|
|
export default function CardDetailPriceHistoryTab({
|
|
points = [],
|
|
priceHistoryLoading,
|
|
formatCurrency,
|
|
}) {
|
|
const chart = buildChartGeometry(points, 640, 220, 24);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<h3 className="text-xl font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
Price History
|
|
</h3>
|
|
|
|
<div className="glass-panel p-6 rounded-xl">
|
|
<h4 className="text-lg font-semibold mb-6" style={{ color: 'var(--text-primary)' }}>
|
|
Price Trend
|
|
</h4>
|
|
|
|
{priceHistoryLoading ? (
|
|
<div className="h-64 flex items-center justify-center">
|
|
<p style={{ color: 'var(--text-secondary)' }} aria-live="polite">Loading price history…</p>
|
|
</div>
|
|
) : points.length === 0 ? (
|
|
<div className="h-64 flex items-center justify-center text-center px-6">
|
|
<p style={{ color: 'var(--text-secondary)' }}>
|
|
No price history yet. Snapshots are recorded during catalog sync — check back after the next update.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<svg
|
|
viewBox="0 0 640 220"
|
|
className="w-full h-56"
|
|
role="img"
|
|
aria-label="Card price history chart"
|
|
>
|
|
<defs>
|
|
<linearGradient id="price-history-fill" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="0%" stopColor="var(--accent-flame)" stopOpacity="0.35" />
|
|
<stop offset="100%" stopColor="var(--accent-flame)" stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
<polygon
|
|
points={chart.area}
|
|
fill="url(#price-history-fill)"
|
|
/>
|
|
<polyline
|
|
points={chart.polyline}
|
|
fill="none"
|
|
stroke="var(--accent-flame)"
|
|
strokeWidth="2.5"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
<div className="flex justify-between text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
<span>{points[0]?.date}</span>
|
|
<span>
|
|
Low {formatCurrency(chart.min)} · High {formatCurrency(chart.max)}
|
|
</span>
|
|
<span>{points[points.length - 1]?.date}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{points.length > 0 && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<div className="glass-panel p-6 rounded-xl">
|
|
<div className="text-center">
|
|
<div className="text-2xl mb-2" aria-hidden="true">💰</div>
|
|
<h5 className="font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>
|
|
Latest Price
|
|
</h5>
|
|
<div className="text-xl font-bold gradient-text-gold">
|
|
{formatCurrency(points[points.length - 1].amount)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|