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>
130 lines
4.4 KiB
JavaScript
130 lines
4.4 KiB
JavaScript
function formatUpdatedLabel(pricesUpdatedAt) {
|
|
if (!pricesUpdatedAt) return 'Price date unknown';
|
|
const date = new Date(pricesUpdatedAt);
|
|
if (Number.isNaN(date.getTime())) return 'Price date unknown';
|
|
return `Updated ${date.toLocaleDateString(undefined, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})}`;
|
|
}
|
|
|
|
export function stalenessBadgeText({ stale, pricesUpdatedAt }) {
|
|
if (stale) return 'Prices may be outdated';
|
|
return formatUpdatedLabel(pricesUpdatedAt);
|
|
}
|
|
|
|
import CardDetailLocalStores from './CardDetailLocalStores';
|
|
|
|
export default function CardDetailPurchaseTab({
|
|
card,
|
|
pricing,
|
|
pricingLoading,
|
|
formatCurrency,
|
|
nearbyStores = [],
|
|
nearbyStoresLoading = false,
|
|
nearbyStoresError = null,
|
|
nearbyStoresNoHomeZip = false,
|
|
}) {
|
|
const displayPrice = pricing?.prices?.current ?? pricing?.prices?.market ?? card?.current_price ?? null;
|
|
const foilPrice = pricing?.prices?.market ?? card?.market_price ?? null;
|
|
const badgeText = pricing ? stalenessBadgeText(pricing) : null;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<h3 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
Where to Buy
|
|
</h3>
|
|
{badgeText && (
|
|
<span
|
|
className="px-3 py-1 rounded-full text-xs font-medium"
|
|
style={{
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
color: 'var(--text-secondary)',
|
|
}}
|
|
data-testid="pricing-staleness-badge"
|
|
>
|
|
{badgeText}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="glass-panel p-6 rounded-xl">
|
|
<h4 className="text-lg font-semibold mb-4" style={{ color: 'var(--text-primary)' }}>
|
|
Online Price Estimate
|
|
</h4>
|
|
{pricingLoading ? (
|
|
<p style={{ color: 'var(--text-secondary)' }} aria-live="polite">Loading prices…</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>NM / Non-foil</p>
|
|
<p className="text-2xl font-bold gradient-text-gold">
|
|
{formatCurrency(displayPrice || 0)}
|
|
</p>
|
|
</div>
|
|
{foilPrice != null && foilPrice !== displayPrice && (
|
|
<div>
|
|
<p className="text-sm mb-1" style={{ color: 'var(--text-secondary)' }}>Foil / Market</p>
|
|
<p className="text-2xl font-bold gradient-text-gold">
|
|
{formatCurrency(foilPrice)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{pricing?.outbound?.tcgplayer && (
|
|
<a
|
|
href={pricing.outbound.tcgplayer}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl" aria-hidden="true">🃏</span>
|
|
<div>
|
|
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
TCGPlayer
|
|
</h4>
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
View on TCGPlayer
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
)}
|
|
{pricing?.outbound?.ebay && (
|
|
<a
|
|
href={pricing.outbound.ebay}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="glass-panel p-6 rounded-xl transition-all duration-200 hover:shadow-lg hover:scale-105"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl" aria-hidden="true">🛒</span>
|
|
<div>
|
|
<h4 className="font-semibold" style={{ color: 'var(--text-primary)' }}>
|
|
eBay
|
|
</h4>
|
|
<p className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
|
View on eBay
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</a>
|
|
)}
|
|
</div>
|
|
|
|
<CardDetailLocalStores
|
|
stores={nearbyStores}
|
|
loading={nearbyStoresLoading}
|
|
error={nearbyStoresError}
|
|
noHomeZip={nearbyStoresNoHomeZip}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|