From 196166456b0984419b4e2a767688269e0b32dc3b Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Wed, 13 May 2026 16:14:56 +0530 Subject: [PATCH] feat: card autocomplete, search filters, lazy PSA, relevance filtering Autocomplete: TCGdex EN+JP database (29K cards) with card preview on hover, EN->JP Pokemon name mapping, cardId linking to our card system. Search filters: format (raw/slab), multi-select source pills, condition dropdown. Fast card-first search flow via card share endpoint (2-3s). Lazy PSA: search returns without waiting for PSA, frontend fetches separately. Pre-warm: track-prices caches active+PSA for tracked cards. eBay relevance filtering expanded (cases, sleeves, playmats, boosters). Fixed GRADED badge on raw cards (Ungraded treated as not-slab). 271 tests (128 unit + 80 API + 63 smoke). --- CHANGELOG.md | 9 +- api.js | 45 ++++-- lib/data/card-database.js | 213 +++++++++++++++++++++++++++ lib/data/card-identity.js | 2 +- lib/search/filters.js | 18 +++ public/app.js | 298 +++++++++++++++++++++++++++++++++++++- public/index.html | 41 ++++++ public/style.css | 30 +++- test/smoke-test.js | 23 +++ test/unit-test.js | 53 +++++++ 10 files changed, 711 insertions(+), 21 deletions(-) create mode 100644 lib/data/card-database.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 786bb38..acc54ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ## Unreleased ### Added +- Card autocomplete: GET /api/autocomplete with TCGdex EN+JP database (29K cards), card preview images, EN→JP name mapping +- Search filters: format (raw/slab), multi-select source pills, condition dropdown, slab provider+grade selectors +- Autocomplete dropdown on dashboard: card thumbnails, card preview panel on hover, keyboard navigation +- Lazy PSA loading: search returns results without waiting for PSA, frontend fetches PSA separately +- Pre-warm cache: track-prices scheduler pre-caches active listings + PSA for tracked cards +- Fast card-first search: autocomplete → card share → demo search → render in 2-3s (was 30s) +- eBay relevance filtering: blocklist expanded (art case, sleeves, playmat, booster, etc.), applied to active+sold - Arbitrage alerts: notify when cross-source spread exceeds threshold (POST /api/alerts with type "arbitrage") - Price drop alerts: notify when price falls below target (POST /api/alerts with type "price") - check-alerts endpoint (owner-only): evaluates all active alerts against live data @@ -51,7 +58,7 @@ - Card identity: cleaned up long names (strips pack names, condition text from titles) - track-prices: now also tracks cards from active alerts, not just 3 hardcoded defaults - Demo condition filter: checks detectedCondition in addition to raw condition field -- Tests: 238 total (118 unit + 80 API + 40 smoke), up from 183 +- Tests: 271 total (128 unit + 80 API + 63 smoke), up from 183 - AI grading prompts: full PSA rubric (5-10), perspective correction, per-corner/edge detail, holo-specific surface guidance - Demo grades re-evaluated with improved prompts (more conservative scores, honest confidence) - Removed dead code: Redis import from api.js, updateCardField from card-identity.js diff --git a/api.js b/api.js index 076435b..9c975c7 100644 --- a/api.js +++ b/api.js @@ -23,6 +23,7 @@ import { recordSoldPrices, getPriceHistory } from "./lib/data/price-history.js"; import { sendAlertEmail } from "./lib/data/email.js"; import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js"; import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/data/card-identity.js"; +import { initCardDatabase, searchCards, refreshCardDatabase } from "./lib/data/card-database.js"; import { fileURLToPath } from "url"; import path from "path"; @@ -253,22 +254,27 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType = const cp = cachePrefix(req); config._cachePrefix = cp; const soldTimeout = (p) => Promise.race([p, new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000))]); - const [activeRes, soldRes, psaSignal] = await Promise.all([ + const [activeRes, soldRes] = await Promise.all([ searchActive({ query: ebayQuery, relevanceQuery: q, deliveryCountries: config.deliveryCountries, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401 }), soldTimeout(searchSold({ query: ebayQuery, relevanceQuery: q, languages: config.languages, config, refresh: false, noEbay: false, getToken, on401, soldBrowser: false })), - getPsaGradingSignal(q, { _cachePrefix: cp }), ]); + const filteredByCountry = {}; + for (const [country, items] of Object.entries(activeRes.itemsByCountry || {})) { + filteredByCountry[country] = filterRelevantResults(items, q).filtered; + } + const filteredSold = filterRelevantResults(soldRes.items || [], q).filtered; + getPsaGradingSignal(q, { _cachePrefix: cp }).catch(() => null); result = { query: q, source: "ebay", listingFormat: config.listingFormat, - activeByCountry: activeRes.itemsByCountry || {}, - sold: soldRes.items || [], + activeByCountry: filteredByCountry, + sold: filteredSold, soldSource: soldRes.source, - psaSignal, + psaSignal: null, counts: { - activeTotal: Object.values(activeRes.itemsByCountry || {}).reduce((n, arr) => n + arr.length, 0), - sold: (soldRes.items || []).length, + activeTotal: Object.values(filteredByCountry).reduce((n, arr) => n + arr.length, 0), + sold: filteredSold.length, }, }; } @@ -354,7 +360,7 @@ app.get("/api/sold", apiAuthMiddleware, (req, res, next) => { req._errorType = " }); // GET /api/psa -app.get("/api/psa", authMiddleware, (req, res, next) => { req._errorType = "psa"; next(); }, async (req, res) => { +app.get("/api/psa", apiAuthMiddleware, (req, res, next) => { req._errorType = "psa"; next(); }, async (req, res) => { const { q } = req.query; if (!validateQuery(q, res)) return; try { @@ -452,6 +458,16 @@ app.get("/api/health", async (req, res) => { }); }); +// GET /api/autocomplete +app.get("/api/autocomplete", (req, res) => { + const q = (req.query.q || "").trim(); + if (!q || q.length < 2) return res.status(400).json({ error: "Query must be at least 2 characters" }); + if (q.length > 100) return res.status(400).json({ error: "Query too long (max 100 characters)" }); + const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 8)); + const results = searchCards(q, limit); + res.json({ results, count: results.length, query: q }); +}); + // ============ V1 API — Drop Intelligence ============ async function authMiddleware(req, res, next) { @@ -1381,9 +1397,14 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { if (hasEbay) { try { const ebayQuery = buildEbaySearchQuery(card, {}); - const soldRes = await Promise.race([ - searchSold({ query: ebayQuery, relevanceQuery: card, languages: [], config: {}, refresh: false, noEbay: false, getToken, on401, soldBrowser: false }), - new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000)), + const warmConfig = { deliveryCountries: ["US", "IN"], languages: [], _cachePrefix: "" }; + const [soldRes] = await Promise.all([ + Promise.race([ + searchSold({ query: ebayQuery, relevanceQuery: card, languages: [], config: {}, refresh: false, noEbay: false, getToken, on401, soldBrowser: false }), + new Promise(r => setTimeout(() => r({ items: [], source: "timeout" }), 30000)), + ]), + searchActive({ query: ebayQuery, relevanceQuery: card, deliveryCountries: warmConfig.deliveryCountries, languages: warmConfig.languages, config: warmConfig, refresh: false, noEbay: false, getToken, on401 }).catch(() => null), + getPsaGradingSignal(card, { _cachePrefix: "" }).catch(() => null), ]); ebaySold = soldRes.items || []; if (ebaySold.length) { @@ -1451,6 +1472,7 @@ app.post("/api/track-prices", authMiddleware, async (req, res) => { } } catch {} + refreshCardDatabase().catch(() => {}); res.json({ tracked: results.length, results, portfolioSnapshots }); }); @@ -1660,4 +1682,5 @@ app.listen(PORT, async () => { console.warn(`eBay token warmup failed: ${e.message}`); } } + initCardDatabase().catch(() => {}); }); diff --git a/lib/data/card-database.js b/lib/data/card-database.js new file mode 100644 index 0000000..0ee5ce8 --- /dev/null +++ b/lib/data/card-database.js @@ -0,0 +1,213 @@ +import { SET_NAME_MAP, SET_TOTAL_MAP } from "./card-identity.js"; + +const JA_TO_EN = { + "ピカチュウ": "Pikachu", "リザードン": "Charizard", "ブラッキー": "Umbreon", + "ゲッコウガ": "Greninja", "ミュウツー": "Mewtwo", "ミュウ": "Mew", + "ルカリオ": "Lucario", "ゲンガー": "Gengar", "レックウザ": "Rayquaza", + "カイリュー": "Dragonite", "ギャラドス": "Gyarados", "カメックス": "Blastoise", + "フシギバナ": "Venusaur", "エーフィ": "Espeon", "ニンフィア": "Sylveon", + "サーナイト": "Gardevoir", "ルギア": "Lugia", "ホウオウ": "Ho-Oh", + "パルキア": "Palkia", "ディアルガ": "Dialga", "ギラティナ": "Giratina", + "アルセウス": "Arceus", "ゼルネアス": "Xerneas", "イベルタル": "Yveltal", + "ソルガレオ": "Solgaleo", "ルナアーラ": "Lunala", "ザシアン": "Zacian", + "ザマゼンタ": "Zamazenta", "コライドン": "Koraidon", "ミライドン": "Miraidon", + "リーフィア": "Leafeon", "グレイシア": "Glaceon", "ブースター": "Flareon", + "シャワーズ": "Vaporeon", "サンダース": "Jolteon", "テラパゴス": "Terapagos", + "ドラパルト": "Dragapult", "セグレイブ": "Baxcalibur", "ドドゲザン": "Kingambit", + "マスカーニャ": "Meowscarada", "ラウドボーン": "Skeledirge", "ウェーニバル": "Quaquaval", + "オーガポン": "Ogerpon", "テツノカイナ": "Iron Hands", "トドロクツキ": "Roaring Moon", + "イーブイ": "Eevee", "ロトム": "Rotom", "ピジョット": "Pidgeot", + "フーディン": "Alakazam", "カイリキー": "Machamp", "ハッサム": "Scizor", + "バンギラス": "Tyranitar", "ボーマンダ": "Salamence", "メタグロス": "Metagross", + "ガブリアス": "Garchomp", "エルレイド": "Gallade", "トゲキッス": "Togekiss", + "ゾロアーク": "Zoroark", "サザンドラ": "Hydreigon", "バシャーモ": "Blaziken", + "ジュカイン": "Sceptile", "ラグラージ": "Swampert", "ミミッキュ": "Mimikyu", + "ドラゴンクロー": "Dragon Claw", "メガゲッコウガ": "Mega Greninja", + "メガリザードン": "Mega Charizard", "メガミュウツー": "Mega Mewtwo", + "メガレックウザ": "Mega Rayquaza", "メガルカリオ": "Mega Lucario", + "メガゲンガー": "Mega Gengar", "メガハッサム": "Mega Scizor", +}; + +function translateJaName(jaName) { + if (!jaName) return null; + const sorted = Object.keys(JA_TO_EN).sort((a, b) => b.length - a.length); + for (const ja of sorted) { + if (jaName.startsWith(ja)) { + const suffix = jaName.slice(ja.length); + const enSuffix = suffix ? ` ${suffix}` : ""; + return `${JA_TO_EN[ja]}${enSuffix}`; + } + } + return null; +} + +let cardIndex = []; + +async function fetchCards(lang, timeout = 15000) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(`https://api.tcgdex.net/v2/${lang}/cards`, { signal: controller.signal }); + if (!res.ok) throw new Error(`TCGdex ${lang} returned ${res.status}`); + return await res.json(); + } finally { + clearTimeout(timer); + } +} + +function parseSetCode(id) { + const m = id.match(/^([^-]+)-/); + return m ? m[1] : null; +} + +const setCodeToTotal = new Map(); +for (const [total, code] of Object.entries(SET_TOTAL_MAP)) { + setCodeToTotal.set(code, total); +} + +function buildCanonicalCardId(setCode, localId) { + if (!setCode || !localId) return null; + const code = setCode.toLowerCase(); + const total = setCodeToTotal.get(code); + if (!total) return null; + return `${code}/${localId}-${total}`; +} + +function buildImageUrl(image) { + if (image) return `${image}/low.png`; + return null; +} + +export async function initCardDatabase() { + const [enCards, jaCards] = await Promise.all([ + fetchCards("en").catch(() => []), + fetchCards("ja").catch(() => []), + ]); + + const jaMap = new Map(); + for (const card of jaCards) { + jaMap.set(card.id, card); + } + + const seen = new Set(); + const index = []; + + for (const card of enCards) { + seen.add(card.id); + const setCode = parseSetCode(card.id); + const jaCard = jaMap.get(card.id); + index.push({ + id: card.id, + name: card.name, + nameJa: jaCard?.name || null, + localId: card.localId, + setCode, + imageUrl: buildImageUrl(card.image), + }); + } + + for (const card of jaCards) { + if (seen.has(card.id)) continue; + const setCode = parseSetCode(card.id); + const enName = translateJaName(card.name); + index.push({ + id: card.id, + name: enName || card.name, + nameJa: card.name, + localId: card.localId, + setCode, + imageUrl: buildImageUrl(card.image), + }); + } + + cardIndex = index; + console.log(`Card database loaded: ${cardIndex.length} cards`); + return cardIndex.length; +} + +export async function refreshCardDatabase() { + return initCardDatabase(); +} + +export function getCardCount() { + return cardIndex.length; +} + +export function matchesQuery(card, query) { + if (!query || query.length < 2) return 0; + const q = query.toLowerCase().trim(); + const tokens = q.split(/\s+/).filter(Boolean); + if (!tokens.length) return 0; + + const nameLower = (card.name || "").toLowerCase(); + const nameJa = card.nameJa || ""; + const idLower = (card.id || "").toLowerCase(); + const localId = card.localId || ""; + + if (tokens.length === 1) { + const t = tokens[0]; + if (nameLower.startsWith(t)) return 3; + if (nameJa.startsWith(t)) return 3; + if (nameLower.includes(t)) return 2; + if (nameJa.includes(t)) return 2; + if (localId.startsWith(t)) return 1; + if (idLower.includes(t)) return 1; + return 0; + } + + const nameFields = `${nameLower} ${nameJa.toLowerCase()}`; + const combined = `${nameFields} ${idLower} ${localId}`.toLowerCase(); + + const allInName = tokens.every(t => nameFields.includes(t)); + if (allInName) { + return nameLower.startsWith(tokens[0]) || nameJa.startsWith(tokens[0]) ? 4 : 3; + } + + const allInCombined = tokens.every(t => combined.includes(t)); + if (allInCombined) { + const hasLocalIdMatch = tokens.some(t => localId === t || localId.startsWith(t)); + const hasNameMatch = tokens.some(t => nameLower.startsWith(t) || nameJa.startsWith(t)); + if (hasLocalIdMatch && hasNameMatch) return 5; + return 1; + } + + const nameMatched = tokens.filter(t => nameFields.includes(t)); + if (nameMatched.length >= 2 && (nameLower.startsWith(tokens[0]) || nameJa.startsWith(tokens[0]))) { + return 1 + Math.min(nameMatched.length / tokens.length, 1); + } + + if (nameMatched.length >= 1 && tokens.some(t => nameLower.startsWith(t) || nameJa.startsWith(t))) { + return 1 + Math.min(nameMatched.length / tokens.length, 0.5); + } + + return 0; +} + +export function searchCards(query, limit = 8) { + if (!query || query.length < 2) return []; + + const scored = []; + for (const card of cardIndex) { + const score = matchesQuery(card, query); + if (score > 0) scored.push({ card, score }); + } + + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const aImg = a.card.imageUrl ? 0 : 1; + const bImg = b.card.imageUrl ? 0 : 1; + if (aImg !== bImg) return aImg - bImg; + return (a.card.name || "").localeCompare(b.card.name || ""); + }); + + return scored.slice(0, limit).map(({ card }) => ({ + id: card.id, + cardId: buildCanonicalCardId(card.setCode, card.localId), + name: card.name, + nameJa: card.nameJa, + setCode: card.setCode, + setName: (card.setCode && SET_NAME_MAP[card.setCode.toLowerCase()]) || card.setCode || null, + localId: card.localId, + imageUrl: card.imageUrl, + })); +} diff --git a/lib/data/card-identity.js b/lib/data/card-identity.js index 3f6f623..bc1a66b 100644 --- a/lib/data/card-identity.js +++ b/lib/data/card-identity.js @@ -13,7 +13,7 @@ function extractCardNumber(text) { return m ? `${m[1]}/${m[2]}` : null; } -const SET_TOTAL_MAP = { +export const SET_TOTAL_MAP = { // Scarlet & Violet era (JP) "78": "sv5k", // Wild Force "81": "sv5m", // Cyber Judge (sub) diff --git a/lib/search/filters.js b/lib/search/filters.js index 9d94c6d..36817ab 100644 --- a/lib/search/filters.js +++ b/lib/search/filters.js @@ -74,6 +74,24 @@ const BLOCKLIST = [ "art card", "metal card", "oversized", + "art case", + "extended art case", + "card case", + "case card", + "alloy case", + "acrylic case", + "display case", + "deck box", + "start deck", + "sleeves", + "playmat", + "binder", + "booster box", + "booster pack", + "etb", + "elite trainer", + "collection box", + "tin box", ]; /** diff --git a/public/app.js b/public/app.js index d664bcd..3456501 100644 --- a/public/app.js +++ b/public/app.js @@ -18,6 +18,164 @@ let currentCondition = ""; let forceDemo = false; let isDemo = false; let allItems = []; + +// ── Autocomplete ── + +const acDropdown = document.getElementById("autocomplete-dropdown"); +const acPreview = document.getElementById("ac-preview"); +let acDebounce = null; +let acIndex = -1; +let acResults = []; + +input.addEventListener("input", () => { + clearTimeout(acDebounce); + const q = input.value.trim(); + if (q.length < 2) { hideAc(); return; } + acDebounce = setTimeout(() => fetchAc(q), 150); +}); + +input.addEventListener("keydown", (e) => { + if (acDropdown.classList.contains("hidden")) return; + const items = acDropdown.querySelectorAll(".ac-item"); + if (e.key === "ArrowDown") { e.preventDefault(); acIndex = Math.min(acIndex + 1, items.length - 1); highlightAc(items); } + else if (e.key === "ArrowUp") { e.preventDefault(); acIndex = Math.max(acIndex - 1, 0); highlightAc(items); } + else if (e.key === "Enter") { e.preventDefault(); if (acIndex >= 0) selectAc(items[acIndex], acIndex); else if (items.length > 0) selectAc(items[0], 0); } + else if (e.key === "Escape") { hideAc(); } +}); + +document.addEventListener("click", (e) => { if (!e.target.closest(".search-bar")) hideAc(); }); + +async function fetchAc(q) { + try { + const res = await fetch(`/api/autocomplete?q=${encodeURIComponent(q)}&limit=6`); + if (!res.ok) { hideAc(); return; } + const data = await res.json(); + renderAc(data.results || []); + } catch { hideAc(); } +} + +function showAcPreview(idx) { + const c = acResults[idx]; + if (!c || !c.imageUrl) { acPreview.classList.add("hidden"); return; } + const highRes = c.imageUrl.replace("/low.png", "/high.png"); + acPreview.innerHTML = `${c.name}
${c.name}${c.nameJa && c.nameJa !== c.name ? ' ' + c.nameJa + '' : ''}
${c.setName || c.setCode || ""} / ${c.localId || ""}
`; + acPreview.classList.remove("hidden"); +} + +function renderAc(results) { + if (!results.length) { hideAc(); return; } + acIndex = -1; + acResults = results; + acDropdown.innerHTML = results.map(c => { + const img = c.imageUrl + ? `` + : `
?
`; + const name = c.nameJa && c.nameJa !== c.name ? `${c.name} ${c.nameJa}` : c.name; + const meta = [c.setName || c.setCode, c.localId].filter(Boolean).join(" / "); + const cardId = c.cardId ? `${c.cardId}` : ""; + const cardNum = c.cardId ? c.cardId.split("/")[1]?.replace("-", "/") : ""; + const searchQuery = cardNum + ? `${c.name} ${cardNum} ${c.setName || ""}`.trim() + : c.name; + return `
${img}
${name}${meta}
${cardId}
`; + }).join(""); + acDropdown.classList.remove("hidden"); + acDropdown.querySelectorAll(".ac-item").forEach((el, i) => { + el.addEventListener("mousedown", (e) => { e.preventDefault(); selectAc(el, i); }); + el.addEventListener("mouseenter", () => { acIndex = i; highlightAc(acDropdown.querySelectorAll(".ac-item")); showAcPreview(i); }); + }); +} + +function highlightAc(items) { + items.forEach((el, i) => el.classList.toggle("ac-active", i === acIndex)); + if (items[acIndex]) { items[acIndex].scrollIntoView({ block: "nearest" }); showAcPreview(acIndex); } +} + +function selectAc(el, idx) { + const q = el.dataset.query.trim(); + input.value = q; + const card = acResults[idx ?? acIndex] || {}; + hideAc(); + currentQuery = q; + + if (card.cardId) { + searchByCardId(card); + } else { + const lang = card.nameJa ? "jp" : ""; + let url = `/api/search?q=${encodeURIComponent(q)}`; + if (forceDemo) url += `&demo=true`; + if (lang) url += `&lang=${lang}`; + searchUrl(url, q); + } + currentSource = ""; + currentCondition = ""; + forceDemo = false; +} + +async function searchByCardId(card) { + btn.disabled = true; + btn.innerHTML = 'Searching'; + emptyState.classList.add("hidden"); + resultsSection.classList.add("hidden"); + alertSection.classList.add("hidden"); + + const [setCode, number] = card.cardId.split("/"); + const shareUrl = `/api/card/share/${setCode}/${number}`; + const searchName = card.name || card.nameJa || ""; + + try { + const shareRes = await fetch(shareUrl); + if (shareRes.ok) { + const shareData = await shareRes.json(); + if (shareData.psaSignal) currentPsaSignal = shareData.psaSignal; + + const searchQ = shareData.searchQuery || `${searchName} ${number.replace("-", "/")}`; + const searchRes = await fetch(`/api/search?q=${encodeURIComponent(searchQ)}&demo=true`); + if (searchRes.ok) { + const data = await searchRes.json(); + if (data.psaSignal == null && shareData.psaSignal) data.psaSignal = shareData.psaSignal; + isDemo = !!data._demo; + render(data); + btn.disabled = false; + btn.textContent = "Search"; + + fetchLiveSources(searchName, card).catch(() => {}); + return; + } + } + } catch {} + + const lang = card.nameJa ? "jp" : ""; + let url = `/api/search?q=${encodeURIComponent(input.value.trim())}&grade=true`; + if (lang) url += `&lang=${lang}`; + searchUrl(url, input.value.trim()); +} + +async function fetchLiveSources(name, card) { + const q = `${name} ${card.cardId.split("/")[1]?.replace("-", "/") || ""}`.trim(); + const sources = ["magi", "yahoo", "snkrdunk"]; + const results = await Promise.allSettled( + sources.map(s => fetch(`/api/search?q=${encodeURIComponent(q)}&source=${s}&grade=true`).then(r => r.json())) + ); + let added = 0; + for (let i = 0; i < sources.length; i++) { + if (results[i].status !== "fulfilled") continue; + const data = results[i].value; + const items = Object.values(data.activeByCountry || {}).flat(); + if (items.length) { + allActive.push(...items); + added += items.length; + } + if (data.sold?.length) allSold.push(...data.sold); + } + if (added > 0) { + renderList(sortItems(allActive, currentSort), "active"); + document.querySelector('.list-tab[data-tab="active"]').textContent = `Active (${allActive.length})`; + document.querySelector('.list-tab[data-tab="sold"]').textContent = `Sold (${allSold.length})`; + } +} + +function hideAc() { acDropdown.classList.add("hidden"); acPreview.classList.add("hidden"); acDropdown.innerHTML = ""; acIndex = -1; acResults = []; } let allActive = []; let allSold = []; let activeSourceFilter = "all"; @@ -51,6 +209,110 @@ document.getElementById("sort-select").addEventListener("change", (e) => { applySourceFilter(); }); +// ── Search filters ── + +let currentFormat = "raw"; +let activeSources = new Set(["ebay", "magi", "yahoo", "snkrdunk"]); +let currentFilterCondition = ""; + +document.querySelectorAll('.filter-pill[data-format]').forEach(btn => { + btn.addEventListener("click", () => { + document.querySelectorAll('.filter-pill[data-format]').forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + currentFormat = btn.dataset.format; + document.getElementById("slab-options").classList.toggle("hidden", currentFormat !== "slab"); + triggerFilterSearch(); + }); +}); + +const allSources = ["ebay", "magi", "yahoo", "snkrdunk"]; +const allPill = document.querySelector('.source-pill[data-source="all"]'); + +function syncSourcePills() { + const allActive = allSources.every(s => activeSources.has(s)); + allPill.classList.toggle("active", allActive); + document.querySelectorAll('.source-pill:not([data-source="all"])').forEach(b => { + b.classList.toggle("active", activeSources.has(b.dataset.source)); + }); +} + +allPill.addEventListener("click", () => { + const allActive = allSources.every(s => activeSources.has(s)); + if (allActive) return; + activeSources = new Set(allSources); + syncSourcePills(); + triggerFilterSearch(); +}); + +document.querySelectorAll('.source-pill:not([data-source="all"])').forEach(btn => { + btn.addEventListener("click", () => { + const src = btn.dataset.source; + if (activeSources.has(src)) { + if (activeSources.size > 1) activeSources.delete(src); + } else { + activeSources.add(src); + } + syncSourcePills(); + triggerFilterSearch(); + }); +}); + +document.getElementById("condition-filter").addEventListener("change", (e) => { + currentFilterCondition = e.target.value; + triggerFilterSearch(); +}); + +document.getElementById("slab-provider").addEventListener("change", () => triggerFilterSearch()); +document.getElementById("slab-grade").addEventListener("change", () => triggerFilterSearch()); + +function triggerFilterSearch() { + const q = currentQuery || input.value.trim(); + if (!q) return; + const sources = [...activeSources]; + if (sources.length === 1) { + let url = `/api/search?q=${encodeURIComponent(q)}&source=${sources[0]}`; + if (currentFormat === "slab") { + url += `&format=slab&slab_provider=${document.getElementById("slab-provider").value}&slab_grade=${document.getElementById("slab-grade").value}`; + } + if (currentFilterCondition) url += `&condition=${currentFilterCondition}`; + url += "&grade=true"; + searchUrl(url, q); + } else if (sources.length === allSources.length) { + let url = `/api/search?q=${encodeURIComponent(q)}`; + if (currentFormat === "slab") { + url += `&format=slab&slab_provider=${document.getElementById("slab-provider").value}&slab_grade=${document.getElementById("slab-grade").value}`; + } + if (currentFilterCondition) url += `&condition=${currentFilterCondition}`; + url += "&grade=true"; + searchUrl(url, q); + } else { + btn.disabled = true; + btn.innerHTML = 'Searching'; + emptyState.classList.add("hidden"); + resultsSection.classList.add("hidden"); + const cond = currentFilterCondition ? `&condition=${currentFilterCondition}` : ""; + const fmt = currentFormat === "slab" ? `&format=slab&slab_provider=${document.getElementById("slab-provider").value}&slab_grade=${document.getElementById("slab-grade").value}` : ""; + Promise.allSettled( + sources.map(s => fetch(`/api/search?q=${encodeURIComponent(q)}&source=${s}${cond}${fmt}&grade=true`).then(r => r.json())) + ).then(results => { + const merged = { query: q, source: "multi", listingFormat: currentFormat, activeByCountry: { US: [] }, sold: [], psaSignal: null, counts: { activeTotal: 0, sold: 0 } }; + for (const r of results) { + if (r.status !== "fulfilled") continue; + const d = r.value; + const items = Object.values(d.activeByCountry || {}).flat(); + merged.activeByCountry.US.push(...items); + if (d.sold) merged.sold.push(...d.sold); + if (d.psaSignal && !merged.psaSignal) merged.psaSignal = d.psaSignal; + } + merged.counts.activeTotal = merged.activeByCountry.US.length; + merged.counts.sold = merged.sold.length; + render(merged); + btn.disabled = false; + btn.textContent = "Search"; + }); + } +} + function sortItems(items) { const sorted = [...items]; if (currentSort === "price-desc") { @@ -73,17 +335,20 @@ form.addEventListener("submit", async (e) => { }); async function search(q, source, condition) { + let url = `/api/search?q=${encodeURIComponent(q)}`; + if (forceDemo) url += `&demo=true`; + if (source) url += `&source=${encodeURIComponent(source)}`; + if (condition) url += `&condition=${encodeURIComponent(condition)}`; + return searchUrl(url, q); +} + +async function searchUrl(url, q) { btn.disabled = true; btn.innerHTML = 'Searching'; emptyState.classList.add("hidden"); resultsSection.classList.add("hidden"); alertSection.classList.add("hidden"); - let url = `/api/search?q=${encodeURIComponent(q)}`; - if (forceDemo) url += `&demo=true`; - if (source) url += `&source=${encodeURIComponent(source)}`; - if (condition) url += `&condition=${encodeURIComponent(condition)}`; - try { const res = await fetch(url); if (!res.ok) { @@ -130,7 +395,13 @@ function render(data) { `; currentPsaSignal = data.psaSignal || null; - renderPsa(data.psaSignal); + if (data.psaSignal) { + renderPsa(data.psaSignal); + } else { + psaSignal.classList.add("hidden"); + const psaQuery = data.query.replace(/\s+\d+\/\d+.*$/, "").trim() || data.query; + fetchPsaLazy(psaQuery); + } allActive = active; allSold = sold; @@ -240,6 +511,19 @@ function dedupeActive(data) { return items.sort((a, b) => (a.totalCost || a.price) - (b.totalCost || b.price)); } +async function fetchPsaLazy(query) { + try { + const res = await fetch(`/api/psa?q=${encodeURIComponent(query)}&demo=true`); + if (!res.ok) return; + const data = await res.json(); + const psa = data.signal || data; + if (psa && psa.totalPop != null && currentQuery === query) { + currentPsaSignal = psa; + renderPsa(psa); + } + } catch {} +} + function renderPsa(psa) { if (!psa) { psaSignal.classList.add("hidden"); return; } psaSignal.classList.remove("hidden"); @@ -336,7 +620,7 @@ function selectItem(itemId) { : ""; const grade = item.grade && !item.grade.error ? item.grade : null; - const slabLabel = item.listingGradeLabel || null; + const slabLabel = item.listingGradeLabel && item.listingGradeLabel !== "Ungraded" ? item.listingGradeLabel : null; const shippingText = item.shippingLabel && item.shippingLabel !== "—" && item.shippingLabel !== "Free" ? `+ ${item.shippingLabel} shipping` : item.shippingLabel === "Free" ? "Free shipping" : ""; diff --git a/public/index.html b/public/index.html index 1b483da..ebe4581 100644 --- a/public/index.html +++ b/public/index.html @@ -34,12 +34,53 @@

Research any Pokemon card
in seconds

Try:
+
+
+ Format + + +
+ +
+ Source + + + + + +
+
+ Condition + +
+
diff --git a/public/style.css b/public/style.css index 0610d6f..81c07d4 100644 --- a/public/style.css +++ b/public/style.css @@ -146,13 +146,14 @@ main { .search-bar { display: flex; + flex-wrap: wrap; max-width: 620px; margin: 0 auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); - overflow: hidden; transition: border-color 0.2s; + position: relative; } .search-bar:focus-within { border-color: rgba(217, 182, 118, 0.4); @@ -1290,6 +1291,33 @@ footer { .portfolio-roi.negative { background: rgba(255,93,93,0.12); color: var(--red); } .portfolio-load-btn { display: block; margin: 20px auto 0; background: transparent; border: 1px solid var(--gold); color: var(--gold); font-family: 'Inter Tight', sans-serif; font-size: 0.9rem; padding: 10px 24px; border-radius: 6px; cursor: pointer; transition: background 0.2s; } .portfolio-load-btn:hover { background: rgba(217,182,118,0.1); } + +/* Search filters */ +.search-filters { display: flex; flex-wrap: wrap; gap: 10px; max-width: 620px; margin: 8px auto 0; padding: 8px 0; align-items: center; justify-content: center; } +.filter-group { display: flex; align-items: center; gap: 6px; } +.filter-label { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; color: rgba(255,255,255,0.4); margin-right: 2px; } +.filter-pill { font-family: 'Inter Tight', sans-serif; font-size: 0.8rem; padding: 5px 12px; border-radius: 9999px; border: 1px solid rgba(255,255,255,0.08); background: transparent; color: rgba(255,255,255,0.5); cursor: pointer; transition: all 0.15s; } +.filter-pill:hover { border-color: rgba(217,182,118,0.3); color: rgba(255,255,255,0.8); } +.filter-pill.active { background: rgba(217,182,118,0.12); border-color: var(--gold); color: var(--gold); } +.filter-select { font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; padding: 5px 8px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); background: var(--inset); color: #fff; cursor: pointer; -webkit-appearance: menulist; appearance: menulist; position: relative; z-index: 50; } +@media (max-width: 768px) { .search-filters { gap: 8px; } .filter-group { flex-wrap: wrap; } } + +/* Autocomplete */ +.search-bar { position: relative; } +.autocomplete-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--panel); border: 1px solid rgba(255,255,255,0.08); border-top: none; border-radius: 0 0 10px 10px; max-height: 360px; overflow-y: auto; z-index: 100; } +.ac-item { display: flex; align-items: center; gap: 12px; padding: 10px 14px; cursor: pointer; transition: background 0.15s; } +.ac-item:hover, .ac-item.ac-active { background: var(--inset); } +.ac-img { width: 40px; height: 56px; object-fit: contain; border-radius: 4px; flex-shrink: 0; background: var(--inset); } +.ac-img-placeholder { width: 40px; height: 56px; border-radius: 4px; flex-shrink: 0; background: var(--inset); display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,0.2); font-size: 0.6rem; } +.ac-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.ac-name { font-family: 'Inter Tight', sans-serif; font-size: 0.9rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ac-meta { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: rgba(255,255,255,0.4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ac-cardid { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: var(--gold); background: rgba(217,182,118,0.1); padding: 1px 6px; border-radius: 4px; margin-left: auto; flex-shrink: 0; } +.ac-preview { position: absolute; top: 100%; left: calc(100% + 12px); background: var(--panel); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px; z-index: 101; width: 200px; } +.ac-preview img { width: 100%; border-radius: 8px; display: block; } +.ac-preview .ac-preview-name { font-family: 'Inter Tight', sans-serif; font-size: 0.8rem; color: #fff; margin-top: 8px; text-align: center; } +.ac-preview .ac-preview-set { font-family: 'JetBrains Mono', monospace; font-size: 0.65rem; color: rgba(255,255,255,0.4); text-align: center; margin-top: 2px; } +@media (max-width: 900px) { .ac-preview { display: none; } } .portfolio-load-btn.hidden { display: none; } @media (max-width: 768px) { diff --git a/test/smoke-test.js b/test/smoke-test.js index 1efecc0..a4156e3 100644 --- a/test/smoke-test.js +++ b/test/smoke-test.js @@ -227,6 +227,29 @@ async function run() { await apiPage.close(); + // --- Autocomplete API --- + console.log("\n[Autocomplete API]"); + const acPage = await browser.newPage(); + + const acRes = await acPage.goto(`${BASE}/api/autocomplete?q=pikachu`); + assert(acRes.status() === 200, "autocomplete pikachu returns 200"); + const acBody = await acRes.json(); + assert(Array.isArray(acBody.results), "autocomplete returns results array"); + if (acBody.results.length > 0) { + const first = acBody.results[0]; + assert(typeof first.id === "string", "result has id field"); + assert(typeof first.name === "string", "result has name field"); + assert(first.imageUrl === null || typeof first.imageUrl === "string", "result has imageUrl field"); + } + + const acShort = await acPage.goto(`${BASE}/api/autocomplete?q=a`); + assert(acShort.status() === 400, "autocomplete q=a returns 400 (too short)"); + + const acNoQ = await acPage.goto(`${BASE}/api/autocomplete`); + assert(acNoQ.status() === 400, "autocomplete missing q returns 400"); + + await acPage.close(); + // --- Static assets --- console.log("\n[Static assets]"); const page2 = await browser.newPage(); diff --git a/test/unit-test.js b/test/unit-test.js index 0697273..f51b014 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -24,6 +24,7 @@ import { isDemoQuery, getDemoResult, getDemoSearchResult, listDemoCards, findDem import { parseCardIdentity, buildCardId, SET_NAME_MAP, resolveCardIdToQuery } from "../lib/data/card-identity.js"; import { buildAlertEmailSubject, sendAlertEmail } from "../lib/data/email.js"; import { csvEscape, csvRow } from "../lib/data/csv.js"; +import { matchesQuery, searchCards } from "../lib/data/card-database.js"; let passed = 0; let failed = 0; @@ -990,6 +991,58 @@ test("isGradedCard rejects raw card query", () => { assert(!isGradedCard("Umbreon ex SAR 217/187")); }); +// ── Card database / autocomplete ── + +console.log("\n\x1b[1m=== card database ===\x1b[0m"); + +test("matchesQuery: prefix match scores 3", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: "ブラッキーex", localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "umbr"), 3); +}); + +test("matchesQuery: contains match scores 2", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: "ブラッキーex", localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "breon"), 2); +}); + +test("matchesQuery: JP name prefix scores 3", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: "ブラッキーex", localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "ブラッキー"), 3); +}); + +test("matchesQuery: localId prefix scores 1", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: "ブラッキーex", localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "217"), 1); +}); + +test("matchesQuery: id contains scores 1", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: null, localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "sv8a-217"), 1); +}); + +test("matchesQuery: no match returns 0", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: "ブラッキーex", localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "charizard"), 0); +}); + +test("matchesQuery: empty query returns 0", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: null, localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, ""), 0); +}); + +test("matchesQuery: query under 2 chars returns 0", () => { + const card = { id: "SV8a-217", name: "Umbreon ex", nameJa: null, localId: "217", setCode: "SV8a" }; + eq(matchesQuery(card, "u"), 0); +}); + +test("searchCards: empty query returns empty", () => { + eq(searchCards("", 8).length, 0); +}); + +test("searchCards: query under 2 chars returns empty", () => { + eq(searchCards("a", 8).length, 0); +}); + // ── Summary ── console.log(`\n\x1b[1m=== ${passed} passed, ${failed} failed ===\x1b[0m\n`);