diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 0d261e6..1db2ede 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,12 @@ ## Summary +## Breaking changes +None / _describe API, schema, or config changes that affect consumers_ + +## Demo data +- [ ] Sample data still works (`?demo=true`) +- [ ] No new fields missing from demo cards + ## Manual verification - - [ ] diff --git a/api.js b/api.js index 9b0c5a6..ee5afc6 100644 --- a/api.js +++ b/api.js @@ -652,12 +652,43 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { const { q } = req.query; if (!validateQuery(q, res)) return; const days = Math.min(365, Math.max(1, Number(req.query.days) || 90)); + + const wantDemo = req.query.demo === "true" || (!clientId && !clientSecret); + if (wantDemo) { + const demoResult = getDemoSearchResult(q, {}); + const sold = demoResult.sold || []; + const history = sold.filter(s => s.soldDate && s.price).map(s => ({ + price: s.price, + recordedAt: s.soldDate, + source: s.itemWebUrl?.includes("magi") ? "magi" : s.itemWebUrl?.includes("yahoo") ? "yahoo" : s.itemWebUrl?.includes("snkrdunk") ? "snkrdunk" : "ebay", + })); + const prices = history.map(h => h.price); + const stats = prices.length ? { + min: Math.min(...prices), + max: Math.max(...prices), + avg: Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100, + count: prices.length, + } : null; + return res.json({ query: q, days, history, stats, _demo: true }); + } + try { let history = await getPriceHistory(q, { days }); + let tcgData = null; + + const tcg = await seedFromTCGPlayer(q); + if (tcg) { + tcgData = { + productId: tcg.productId, + name: tcg.name, + setName: tcg.setName, + marketPrice: tcg.price, + listedMedianPrice: tcg.listedMedianPrice, + printingType: tcg.printingType, + source: "tcgplayer", + }; - if (!history.length) { - const tcg = await seedFromTCGPlayer(q); - if (tcg) { + if (!history.length) { await recordSoldPrices(q, [{ itemId: `tcg_${tcg.productId}`, price: tcg.price, @@ -676,7 +707,13 @@ app.get("/api/price-history", apiAuthMiddleware, async (req, res) => { avg: Math.round(prices.reduce((s, p) => s + p, 0) / prices.length * 100) / 100, count: prices.length, } : null; - res.json({ query: q, days, history, stats }); + + if (tcgData && stats) { + const ratio = tcgData.marketPrice / stats.avg; + if (ratio < 0.3 || ratio > 3) tcgData = null; + } + + res.json({ query: q, days, history, stats, tcgplayer: tcgData }); } catch (e) { logError("price-history", e.message, req.originalUrl, req.requestId); res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); diff --git a/lib/data/demo.js b/lib/data/demo.js index ef87b81..bd3b78f 100644 --- a/lib/data/demo.js +++ b/lib/data/demo.js @@ -93,11 +93,16 @@ const DEMO_CARDS = { ], }, sold: [ - { itemId: "magi-greninja-s1", itemWebUrl: "https://magi.camp/items/magi-greninja-s1", title: "メガゲッコウガex SAR 114/083 1枚", price: 298.46, priceCurrency: "USD", priceJPY: 46800, listingGradeLabel: null, endedDate: "—", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s1", itemWebUrl: "https://magi.camp/items/magi-greninja-s1", title: "メガゲッコウガex SAR 114/083 1枚", price: 298.46, priceCurrency: "USD", priceJPY: 46800, soldDate: "2026-05-10", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s2", itemWebUrl: "https://magi.camp/items/magi-greninja-s2", title: "メガゲッコウガex SAR 114/083 美品", price: 318.47, priceCurrency: "USD", priceJPY: 50000, soldDate: "2026-05-06", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "snkr-greninja-s1", itemWebUrl: "https://snkrdunk.com/products/greninja-s1", title: "MEGA Greninja ex SAR [A — Mint]", price: 345.00, priceCurrency: "USD", soldDate: "2026-04-28", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s3", itemWebUrl: "https://magi.camp/items/magi-greninja-s3", title: "メガゲッコウガex SAR 114/083 1枚", price: 280.25, priceCurrency: "USD", priceJPY: 44000, soldDate: "2026-04-20", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "snkr-greninja-s2", itemWebUrl: "https://snkrdunk.com/products/greninja-s2", title: "MEGA Greninja ex SAR [A — Mint]", price: 310.00, priceCurrency: "USD", soldDate: "2026-04-14", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, + { itemId: "magi-greninja-s4", itemWebUrl: "https://magi.camp/items/magi-greninja-s4", title: "メガゲッコウガex SAR 114/083 1枚", price: 265.92, priceCurrency: "USD", priceJPY: 41750, soldDate: "2026-04-08", imageUrl: "https://cdn.snkrdunk.com/apparel_used_listings/e7caf458-db53-44cc-b9c6-c6dec1ace529/980404.jpeg" }, ], soldSource: "multi", psaSignal: { certNumber: null, totalPop: 285, pop10: 142, pop9: 98, difficulty: "moderate", gem10Pct: 49.8, grade10Chance: "moderate", avgDaysToGrade: 65, tier: "Value", estCost: "$25", tierReason: "Raw at $333–$427, under the $499 Value cap. PSA 10 market still forming — low upcharge risk." }, - counts: { activeTotal: 8, sold: 1 }, + counts: { activeTotal: 8, sold: 6 }, }, "umbreon ex sar 217/187": { @@ -182,13 +187,16 @@ const DEMO_CARDS = { }, sold: [ { itemId: "v1|397899646795|sold1", itemWebUrl: "https://www.ebay.com/itm/397899646795", title: "Umbreon ex SAR 217/187 SV8a Terastal Festival Japanese Pokemon TCG", price: 385.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-08", imageUrl: "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg" }, - { itemId: "v1|177832326093|sold1", itemWebUrl: "https://www.ebay.com/itm/177832326093", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a Pokemon Card Japanese NM", price: 392.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-07", imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l500.jpg" }, - { itemId: "v1|178048869261|sold1", itemWebUrl: "https://www.ebay.com/itm/178048869261", title: "Pokemon Umbreon Ex sv8a 217/187 SAR Special Art Rare Japanese", price: 370.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-06", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" }, - { itemId: "v1|397643034526|sold1", itemWebUrl: "https://www.ebay.com/itm/397643034526", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card", price: 405.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-05", imageUrl: "https://i.ebayimg.com/images/g/8fIAAeSwny1pnSPT/s-l500.jpg" }, + { itemId: "v1|177832326093|sold1", itemWebUrl: "https://www.ebay.com/itm/177832326093", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a Pokemon Card Japanese NM", price: 392.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-05-04", imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l500.jpg" }, + { itemId: "v1|178048869261|sold1", itemWebUrl: "https://www.ebay.com/itm/178048869261", title: "Pokemon Umbreon Ex sv8a 217/187 SAR Special Art Rare Japanese", price: 370.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-28", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" }, + { itemId: "v1|397643034526|sold1", itemWebUrl: "https://www.ebay.com/itm/397643034526", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card", price: 405.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-22", imageUrl: "https://i.ebayimg.com/images/g/8fIAAeSwny1pnSPT/s-l500.jpg" }, + { itemId: "v1|397467499018|sold1", itemWebUrl: "https://www.ebay.com/itm/397467499018", title: "Umbreon ex SAR 217/187 Terastal Festival sv8a Pokemon Card", price: 358.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-15", imageUrl: "https://i.ebayimg.com/images/g/MK8AAeSwqEVpXH5z/s-l500.jpg" }, + { itemId: "magi-umb-sold1", itemWebUrl: "https://magi.camp/item/umb-sold-001", title: "ブラッキーex SAR 217/187 sv8a 1枚", price: 342.36, priceCurrency: "USD", priceJPY: 53800, soldDate: "2026-04-10", imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg" }, + { itemId: "v1|397899646795|sold2", itemWebUrl: "https://www.ebay.com/itm/397899646795", title: "Umbreon ex SAR 217/187 SV8a Terastal Festival Pokemon", price: 375.00, priceCurrency: "USD", condition: "Ungraded", soldDate: "2026-04-03", imageUrl: "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg" }, ], soldSource: "scrape", psaSignal: { certNumber: null, totalPop: 3420, pop10: 1890, pop9: 1105, difficulty: "easy", gem10Pct: 55.3, grade10Chance: "high", avgDaysToGrade: 30, tier: "Regular", estCost: "$50", tierReason: "PSA 10 comps at $750+, above the $499 Value cap. Submitting at Value risks an upcharge to Regular anyway." }, - counts: { activeTotal: 8, sold: 4 }, + counts: { activeTotal: 8, sold: 7 }, }, "pikachu ex sar 234/193 psa 10": { @@ -254,11 +262,11 @@ const DEMO_CARDS = { ], }, sold: [ - { itemId: "1978552265", itemWebUrl: "https://magi.camp/items/1978552265", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 785.61, priceCurrency: "USD", priceJPY: 121770, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/zkYAAeSwNqNpiCOx/s-l500.jpg" }, - { itemId: "j1228450383", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/j1228450383", title: "☆ PSA10 ポケモンカード ピカチュウex 234/193 SAR ☆", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, - { itemId: "w1228860729", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/w1228860729", title: "ポケモンカード ピカチュウex SAR PSA10 GEM MT 234/193 M2a JP", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, - { itemId: "p1227663240", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/p1227663240", title: "ポケモンカード ピカチュウex 234/193 SAR PSA10 PSA鑑定品", price: 819.41, priceCurrency: "USD", priceJPY: 127009, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, - { itemId: "695466231-sold", itemWebUrl: "https://magi.camp/items/695466231", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 741, priceCurrency: "USD", priceJPY: 114855, listingGradeLabel: "PSA 10", endedDate: "—", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, + { itemId: "1978552265", itemWebUrl: "https://magi.camp/items/1978552265", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 785.61, priceCurrency: "USD", priceJPY: 121770, listingGradeLabel: "PSA 10", soldDate: "2026-05-10", imageUrl: "https://i.ebayimg.com/images/g/zkYAAeSwNqNpiCOx/s-l500.jpg" }, + { itemId: "j1228450383", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/j1228450383", title: "☆ PSA10 ポケモンカード ピカチュウex 234/193 SAR ☆", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", soldDate: "2026-05-05", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, + { itemId: "w1228860729", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/w1228860729", title: "ポケモンカード ピカチュウex SAR PSA10 GEM MT 234/193 M2a JP", price: 741.94, priceCurrency: "USD", priceJPY: 115000, listingGradeLabel: "PSA 10", soldDate: "2026-04-28", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, + { itemId: "p1227663240", itemWebUrl: "https://auctions.yahoo.co.jp/jp/auction/p1227663240", title: "ポケモンカード ピカチュウex 234/193 SAR PSA10 PSA鑑定品", price: 819.41, priceCurrency: "USD", priceJPY: 127009, listingGradeLabel: "PSA 10", soldDate: "2026-04-20", imageUrl: "https://i.ebayimg.com/images/g/g~8AAeSw7kpp~IVo/s-l500.jpg" }, + { itemId: "695466231-sold", itemWebUrl: "https://magi.camp/items/695466231", title: "【PSA10】ピカチュウex SAR 234/193 1枚", price: 741, priceCurrency: "USD", priceJPY: 114855, listingGradeLabel: "PSA 10", soldDate: "2026-04-12", imageUrl: "https://i.ebayimg.com/images/g/ZTwAAeSwzrlp~Y3k/s-l500.jpg" }, ], soldSource: "multi", psaSignal: { certNumber: null, totalPop: 8920, pop10: 5870, pop9: 2215, difficulty: "easy", gem10Pct: 65.8, grade10Chance: "high", avgDaysToGrade: 15, tier: "Express", estCost: "$75", tierReason: "PSA 10 comps at $741–$848. Express ($75) halves turnaround vs Regular ($50) — worth $25 on a $700+ card." }, diff --git a/lib/sources/tcgplayer.js b/lib/sources/tcgplayer.js index 83e7b6a..68a3f3c 100644 --- a/lib/sources/tcgplayer.js +++ b/lib/sources/tcgplayer.js @@ -3,7 +3,7 @@ const SEARCH_URL = "https://mp-search-api.tcgplayer.com/v1/search/request?q=QUER const PRICE_URL = "https://mpapi.tcgplayer.com/v2/product/PRODUCT_ID/pricepoints"; export async function searchTCGPlayer(cardName, { limit = 3 } = {}) { - const q = encodeURIComponent(cardName.replace(/\d{3}\/\d{3}/g, "").replace(/SAR|SR|AR/gi, "").trim()); + const q = encodeURIComponent(cardName.trim()); try { const res = await fetch(SEARCH_URL.replace("QUERY", q), { method: "POST", @@ -54,7 +54,11 @@ export async function getTCGPlayerPrice(productId) { } export async function seedFromTCGPlayer(cardName) { - const products = await searchTCGPlayer(cardName, { limit: 1 }); + let products = await searchTCGPlayer(cardName, { limit: 1 }); + if (!products.length) { + const simpler = cardName.replace(/\d{3}\/\d{3}/g, "").replace(/SAR|SR|AR/gi, "").trim(); + if (simpler !== cardName.trim()) products = await searchTCGPlayer(simpler, { limit: 1 }); + } if (!products.length) return null; const product = products[0]; diff --git a/public/app.js b/public/app.js index 1bd75f3..b046f6a 100644 --- a/public/app.js +++ b/public/app.js @@ -23,6 +23,8 @@ let allSold = []; let activeSourceFilter = "all"; let currentSort = "price-asc"; let currentPsaSignal = null; +let currentMagiMarket = null; +let pendingChartPoints = null; document.querySelectorAll(".hint").forEach(h => { h.addEventListener("click", () => { @@ -432,6 +434,10 @@ function selectItem(itemId) { detailPanel.querySelectorAll(".detail-tab-panel").forEach(p => p.classList.add("hidden")); const panel = detailPanel.querySelector(`[data-dtpanel="${tab.dataset.dtab}"]`); if (panel) panel.classList.remove("hidden"); + if (tab.dataset.dtab === "prices" && pendingChartPoints) { + const c = document.getElementById("price-chart"); + if (c && c.parentElement.clientWidth > 0) drawPriceChart(c, pendingChartPoints); + } }); }); @@ -490,6 +496,9 @@ async function loadArbitrage(query) { container.classList.remove("hidden"); const arb = data.arbitrage; + const magiData = sources.magi || sources.Magi; + currentMagiMarket = magiData ? { lowest: magiData.lowest, priceJPY: magiData.priceJPY, count: magiData.count } : null; + const sorted = names.sort((a, b) => sources[a].lowest - sources[b].lowest); const savingsHtml = arb ? (() => { const match = arb.summary.match(/\$[\d,.]+/); @@ -572,9 +581,58 @@ function loadGradingRoi(item) { ? `${gemPct}% gem rate at ${esc(psa.estCost)} grading — favorable odds` : `${gemPct}% gem rate — high risk of PSA 9 or lower`} + ${buildExpectedOutcome(item, psa)} `; } +function buildExpectedOutcome(item, psa) { + const grade = item.grade && !item.grade.error ? item.grade : null; + if (!grade || grade.overall == null) return ""; + + const aiScore = grade.overall; + let expectedPsa, popAtGrade; + if (aiScore >= 9.5) { + expectedPsa = 10; + popAtGrade = psa.pop10; + } else if (aiScore >= 8.5) { + expectedPsa = 9; + popAtGrade = psa.pop9; + } else if (aiScore >= 7.5) { + expectedPsa = 8; + popAtGrade = null; + } else { + expectedPsa = Math.floor(aiScore); + popAtGrade = null; + } + + const popStr = popAtGrade != null ? popAtGrade.toLocaleString() : null; + + let scarcityLabel = ""; + let scarcityClass = ""; + if (popAtGrade != null) { + if (popAtGrade < 100) { + scarcityLabel = "Scarce"; + scarcityClass = "scarce"; + } else if (popAtGrade <= 1000) { + scarcityLabel = "Moderate scarcity"; + scarcityClass = "moderate"; + } else { + scarcityLabel = "Common"; + scarcityClass = "common"; + } + } + + return ` +
+
Expected Outcome
+
+ Likely PSA ${expectedPsa} + ${popStr ? `${popStr} exist` : ""} + ${scarcityLabel ? `${scarcityLabel}` : ""} +
+
`; +} + async function loadPriceChart(query) { const container = document.getElementById("price-chart-container"); const canvas = document.getElementById("price-chart"); @@ -582,7 +640,7 @@ async function loadPriceChart(query) { if (!container || !canvas) return; try { - const res = await fetch(`/api/price-history?q=${encodeURIComponent(query)}&days=90`); + const res = await fetch(`/api/price-history?q=${encodeURIComponent(query)}&days=90&demo=true`); if (!res.ok) return; const data = await res.json(); if (!data.history?.length) return; @@ -593,9 +651,10 @@ async function loadPriceChart(query) { .filter(h => h.price > 0) .sort((a, b) => new Date(a.recordedAt) - new Date(b.recordedAt)); - if (!points.length) return; + if (points.length < 3) return; - drawPriceChart(canvas, points); + pendingChartPoints = points; + if (canvas.parentElement.clientWidth > 0) drawPriceChart(canvas, points); if (data.stats) { statsEl.innerHTML = ` @@ -605,6 +664,22 @@ async function loadPriceChart(query) { ${data.stats.count} sales `; } + + if (data.tcgplayer?.marketPrice) { + const tcg = data.tcgplayer; + const tcgEl = document.createElement("div"); + tcgEl.className = "tcg-market-ref"; + tcgEl.innerHTML = ` +
TCGPlayer Market
+
+ ${formatPrice(tcg.marketPrice, "USD")} + ${tcg.listedMedianPrice ? `Median: ${formatPrice(tcg.listedMedianPrice, "USD")}` : ""} +
+ ${tcg.printingType ? `
${esc(tcg.printingType)}
` : ""} + `; + container.appendChild(tcgEl); + } + } catch {} } diff --git a/public/style.css b/public/style.css index a872551..017a775 100644 --- a/public/style.css +++ b/public/style.css @@ -771,6 +771,51 @@ main { font-size: 12px; color: var(--muted); } +.roi-expected-outcome { + margin-top: 10px; + padding: 10px 12px; + background: var(--inset); + border: 1px solid var(--border); + border-radius: 6px; +} +.roi-expected-label { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 6px; +} +.roi-expected-row { + display: flex; + align-items: center; + gap: 0; + font-family: 'Space Grotesk', system-ui, sans-serif; + font-size: 13px; + color: var(--text); +} +.roi-expected-grade { + font-weight: 700; + color: var(--gold); +} +.roi-expected-sep { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--muted); + margin: 0 10px; + flex-shrink: 0; +} +.roi-expected-pop { + color: var(--text); +} +.roi-expected-scarcity { + font-weight: 600; +} +.roi-expected-scarcity.scarce { color: var(--green); } +.roi-expected-scarcity.moderate { color: var(--gold); } +.roi-expected-scarcity.common { color: var(--muted); } .arbitrage-container { background: var(--inset); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; } .arbitrage-sources { display: flex; gap: 8px; margin-top: 8px; } @@ -830,6 +875,58 @@ main { font-size: 13px; } +.tcg-market-ref { + margin-top: 12px; + padding: 10px 14px; + background: rgba(217, 182, 118, 0.04); + border: 1px solid rgba(217, 182, 118, 0.18); + border-radius: 6px; +} +.tcg-market-label { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--gold); + margin-bottom: 6px; +} +.tcg-market-row { + display: flex; + align-items: baseline; + gap: 12px; +} +.tcg-market-price { + font-family: 'Space Grotesk', system-ui, sans-serif; + font-size: 22px; + font-weight: 700; + color: var(--text); +} +.tcg-market-median { + font-size: 12px; + color: var(--muted); +} +.tcg-market-median b { + color: var(--text); +} +.tcg-market-meta { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted); + margin-top: 4px; +} +.jp-market-ref { + border-color: rgba(124, 224, 168, 0.15); + background: rgba(124, 224, 168, 0.03); +} +.jp-market-source { + font-size: 9px; + color: var(--muted); + font-weight: 400; + margin-left: 6px; +} + .detail-actions { display: flex; gap: 10px;