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.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
+
+
+
+
+ Provider
+
+
+
+
+ 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`);