Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions api.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { searchMagi } from "./lib/sources/magi.js";
import { searchYahooAuctions } from "./lib/sources/yahooauctions.js";
import { getPsaGradingSignal } from "./lib/grading/psa.js";
import { gradeImage } from "./lib/grading/grading.js";
import { parseListingLanguagesFromInput, filterByCondition, detectCondition } from "./lib/search/filters.js";
import { parseListingLanguagesFromInput, filterByCondition, detectCondition, flagPriceOutliers } from "./lib/search/filters.js";
import { buildEbaySearchQuery } from "./lib/search/listingQuery.js";
import { EBAY_CATEGORY_TCG_SINGLE_CARDS_US } from "./lib/search/ebayCategories.js";
import { getRedisStatus, sha256 } from "./lib/data/redis-cache.js";
Expand Down Expand Up @@ -224,9 +224,9 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType =
if (wantDemo) {
const demoResult = getDemoSearchResult(q, { source: req.query.source, condition: req.query.condition });
for (const country of Object.keys(demoResult.activeByCountry || {})) {
demoResult.activeByCountry[country] = demoResult.activeByCountry[country].map(item => ({
demoResult.activeByCountry[country] = flagPriceOutliers(demoResult.activeByCountry[country].map(item => ({
...item, detectedCondition: item.detectedCondition || detectCondition(item),
}));
})));
}
if (demoResult.sold?.length) recordSoldPrices(q, demoResult.sold, demoResult.source).catch(() => {});
return res.json(demoResult);
Expand Down Expand Up @@ -278,12 +278,12 @@ app.get("/api/search", apiAuthMiddleware, (req, res, next) => { req._errorType =
}
}

// Add detected condition to each listing
// Add detected condition + flag outliers
for (const country of Object.keys(result.activeByCountry || {})) {
result.activeByCountry[country] = result.activeByCountry[country].map(item => ({
result.activeByCountry[country] = flagPriceOutliers(result.activeByCountry[country].map(item => ({
...item,
detectedCondition: item.detectedCondition || detectCondition(item),
}));
})));
}

// Filter by condition if requested
Expand Down
10 changes: 5 additions & 5 deletions lib/data/demo.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,15 @@ const DEMO_CARDS = {
{
itemId: "v1|178048869261|0", itemWebUrl: "https://www.ebay.com/itm/178048869261",
title: "Pokemon Indonesia 2025 Umbreon Ex sv8a 217/187 SAR Special Art Rare",
price: 379.90, priceCurrency: "USD", shippingLabel: "$19.00", totalCost: 398.90, condition: "Ungraded",
price: 379.90, priceCurrency: "USD", shippingLabel: "$19.00", totalCost: 398.90, condition: "Ungraded", detectedCondition: "LP",
imageUrl: "https://i.ebayimg.com/images/g/dvIAAeSwCB9p3n5z/s-l500.jpg",
additionalImages: [{ imageUrl: "https://i.ebayimg.com/images/g/WUIAAeSwmlFp3n58/s-l500.jpg" }],
grade: { overall: 7.5, centering: 7.5, corners: 9.0, edges: 9.0, surface: 9.0, confidence: 0.70, mode: "llm-detailed", notes: "Grade limiter: centering — Back centering off, bottom border wider than top ~63/37.", limitations: "", subgradeDetails: { centering: { score: 7.5, confidence: 0.75, detail: "Front ~54/46 acceptable. Back noticeably off — bottom wider than top ~63/37." }, corners: { score: 9.0, confidence: 0.65, detail: "Corners appear clean. No close-up available — reduced confidence." }, edges: { score: 9.0, confidence: 0.62, detail: "Edges look clean from full shots. No close-up detail available." }, surface: { score: 9.0, confidence: 0.78, detail: "Front surface clean, no scratches or print defects visible." } } },
},
{
itemId: "v1|397899646795|0", itemWebUrl: "https://www.ebay.com/itm/397899646795",
title: "Umbreon ex SAR 217/187 SV8a TERASTAL FEST EX Japanese Pokemon TCG",
price: 400, priceCurrency: "USD", shippingLabel: "Free", totalCost: 400, condition: "Ungraded",
price: 400, priceCurrency: "USD", shippingLabel: "Free", totalCost: 400, condition: "Ungraded", detectedCondition: "NM",
imageUrl: "https://i.ebayimg.com/images/g/XYkAAeSw8fBp9JS-/s-l500.jpg",
additionalImages: [
{ imageUrl: "https://i.ebayimg.com/images/g/PcwAAeSw5zhp9JS~/s-l500.jpg" },
Expand All @@ -130,15 +130,15 @@ const DEMO_CARDS = {
{
itemId: "v1|177832326093|0", itemWebUrl: "https://www.ebay.com/itm/177832326093",
title: "Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card Japanese NM",
price: 400, priceCurrency: "USD", shippingLabel: "Free", totalCost: 400, condition: "Ungraded",
price: 400, priceCurrency: "USD", shippingLabel: "Free", totalCost: 400, condition: "Ungraded", detectedCondition: "NM",
imageUrl: "https://i.ebayimg.com/images/g/FTcAAeSwiLRpgfeC/s-l500.jpg",
additionalImages: [{ imageUrl: "https://i.ebayimg.com/images/g/7NIAAeSw8s9pgfeE/s-l500.jpg" }],
grade: { overall: 8.5, centering: 9.0, corners: 9.0, edges: 9.5, surface: 8.5, confidence: 0.74, mode: "llm-detailed", notes: "Grade limiter: surface — Faint holo texture disruption, likely print variation.", limitations: "", subgradeDetails: { centering: { score: 9.0, confidence: 0.76, detail: "Centering solid ~53/47 front. Back appears centered from single photo." }, corners: { score: 9.0, confidence: 0.70, detail: "Corners look clean. Single back photo limits full assessment." }, edges: { score: 9.5, confidence: 0.72, detail: "Edges clean with no whitening visible from available angles." }, surface: { score: 8.5, confidence: 0.78, detail: "Faint holo texture disruption under light. Likely print variation but could cost half a point." } } },
},
{
itemId: "v1|397643034526|0", itemWebUrl: "https://www.ebay.com/itm/397643034526",
title: "With tracking Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card",
price: 415.31, priceCurrency: "USD", shippingLabel: "Free", totalCost: 415.31, condition: "Ungraded",
price: 415.31, priceCurrency: "USD", shippingLabel: "Free", totalCost: 415.31, condition: "Ungraded", detectedCondition: "NM",
imageUrl: "https://i.ebayimg.com/images/g/8fIAAeSwny1pnSPT/s-l500.jpg",
additionalImages: [
{ imageUrl: "https://i.ebayimg.com/images/g/2iYAAeSwV0hpnSPT/s-l500.jpg" },
Expand All @@ -150,7 +150,7 @@ const DEMO_CARDS = {
{
itemId: "v1|397467499018|0", itemWebUrl: "https://www.ebay.com/itm/397467499018",
title: "With tracking Umbreon ex SAR 217/187 Terastal Festival sv8a 2024 Pokemon Card",
price: 425.65, priceCurrency: "USD", shippingLabel: "Free", totalCost: 425.65, condition: "Ungraded",
price: 425.65, priceCurrency: "USD", shippingLabel: "Free", totalCost: 425.65, condition: "Ungraded", detectedCondition: "LP",
imageUrl: "https://i.ebayimg.com/images/g/MK8AAeSwqEVpXH5z/s-l500.jpg",
additionalImages: [
{ imageUrl: "https://i.ebayimg.com/images/g/xrYAAeSwMW1pXH5z/s-l500.jpg" },
Expand Down
122 changes: 97 additions & 25 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ let allItems = [];
let allActive = [];
let allSold = [];
let activeSourceFilter = "all";
let currentSort = "price-asc";
let currentPsaSignal = null;

document.querySelectorAll(".hint").forEach(h => {
Expand All @@ -43,6 +44,21 @@ document.querySelectorAll(".list-tab").forEach(tab => {
});
});

document.getElementById("sort-select").addEventListener("change", (e) => {
currentSort = e.target.value;
applySourceFilter();
});

function sortItems(items) {
const sorted = [...items];
if (currentSort === "price-desc") {
sorted.sort((a, b) => (b.totalCost || b.price) - (a.totalCost || a.price));
} else {
sorted.sort((a, b) => (a.totalCost || a.price) - (b.totalCost || b.price));
}
return sorted;
}

form.addEventListener("submit", async (e) => {
e.preventDefault();
const q = input.value.trim();
Expand Down Expand Up @@ -117,15 +133,21 @@ function render(data) {
allActive = active;
allSold = sold;
activeSourceFilter = "all";
currentSort = "price-asc";
document.getElementById("sort-select").value = "price-asc";

const isMulti = data.source === "multi";
const sources = isMulti ? detectSources(active, sold) : [];
renderSourceFilters(sources);

renderList(activeList, active);
renderList(soldList, sold);
renderList(activeList, sortItems(active));
renderList(soldList, sortItems(sold));

const activeTab = document.querySelector('.list-tab[data-tab="active"]');
const soldTab = document.querySelector('.list-tab[data-tab="sold"]');
activeTab.textContent = `Active (${active.length})`;
soldTab.textContent = `Sold (${sold.length})`;

if (hasGrades && soldTotal === 0) {
soldTab.classList.add("hidden");
} else {
Expand Down Expand Up @@ -187,13 +209,16 @@ function applySourceFilter() {
? () => true
: (item) => itemSource(item.itemWebUrl) === activeSourceFilter;

const filteredActive = allActive.filter(filterFn);
const filteredSold = allSold.filter(filterFn);
const filteredActive = sortItems(allActive.filter(filterFn));
const filteredSold = sortItems(allSold.filter(filterFn));
allItems = [...filteredActive, ...filteredSold];

renderList(activeList, filteredActive);
renderList(soldList, filteredSold);

document.querySelector('.list-tab[data-tab="active"]').textContent = `Active (${filteredActive.length})`;
document.querySelector('.list-tab[data-tab="sold"]').textContent = `Sold (${filteredSold.length})`;

detailPanel.innerHTML = '<div class="detail-empty">Click a listing to inspect</div>';
if (filteredActive.length) selectItem(filteredActive[0].itemId);
}
Expand Down Expand Up @@ -242,7 +267,26 @@ function renderList(container, items) {
const price = formatPrice(item.price, item.priceCurrency);
const imgSrc = item.imageUrl && !item.imageUrl.includes("placeholder") ? item.imageUrl : "";
const imgHtml = imgSrc ? `<img class="thumb" src="${esc(imgSrc)}" alt="" loading="lazy">` : `<div class="thumb"></div>`;
const condition = item.condition ? `<span class="condition">${esc(item.condition)}</span>` : "";

let displayCond = item.condition || "";
if (!item.listingGradeLabel && item.detectedCondition) {
displayCond = item.detectedCondition;
} else if (displayCond === "Ungraded" || displayCond === "ungraded") {
displayCond = item.detectedCondition || "";
}
const useBadge = !item.condition && item.detectedCondition;
const conditionHtml = displayCond
? `<span class="${useBadge ? "condition-badge" : "condition"}">${esc(displayCond)}</span>`
: "";

const shippingHtml = item.shippingLabel === "Free" || item.shippingLabel === "free"
? '<span class="shipping shipping-free">Free shipping</span>'
: item.shippingLabel && item.shippingLabel !== "—"
? `<span class="shipping">+ ${esc(item.shippingLabel)}</span>`
: "";

const outlierHtml = item._priceOutlier ? '<span class="price-outlier">Price outlier</span>' : "";

const gradeChip = item.grade && !item.grade.error
? `<span class="grade-chip" style="color: ${gradeColor(item.grade.overall)}">${item.grade.overall.toFixed(1)}</span>`
: item.listingGradeLabel
Expand All @@ -258,9 +302,11 @@ function renderList(container, items) {
<div class="price-row">
<span class="price">${price}</span>
${gradeChip}
${shippingHtml}
${srcTag}
</div>
${condition}
${conditionHtml}
${outlierHtml}
</div>
</div>
`;
Expand Down Expand Up @@ -319,10 +365,14 @@ function selectItem(itemId) {
`;

const fields = [];
const condVal = item.condition
? (slabLabel ? `${item.condition} <span class="graded-badge">Graded</span>` : item.condition)
: (slabLabel ? `<span class="graded-badge">Graded</span>` : "");
if (condVal) fields.push({ label: "Condition", value: condVal, raw: true });
if (slabLabel) {
fields.push({ label: "Condition", value: `<span class="graded-badge">Graded</span>`, raw: true });
} else {
const condVal = (item.condition === "Ungraded" || item.condition === "ungraded")
? (item.detectedCondition || "")
: (item.detectedCondition || item.condition || "");
if (condVal) fields.push({ label: "Condition", value: condVal });
}
if (item.soldDate || item.endedDate) fields.push({ label: "Sold", value: item.soldDate || item.endedDate });
if (item.priceJPY) fields.push({ label: "Price (JPY)", value: `¥${item.priceJPY.toLocaleString()}` });
if (item.totalCost && item.totalCost !== item.price) fields.push({ label: "Item Price", value: formatPrice(item.price, item.priceCurrency) });
Expand Down Expand Up @@ -351,8 +401,8 @@ function selectItem(itemId) {
${summaryHtml}
<div class="detail-meta-row">
${fieldsHtml}
<div id="card-identity" class="card-identity hidden"></div>
</div>
<div id="card-identity" class="card-identity hidden"></div>
<div class="detail-tabs">
${hasGrade ? `<button class="detail-tab${defaultTab === "grade" ? " active" : ""}" data-dtab="grade">Grade</button>` : ""}
<button class="detail-tab${defaultTab === "prices" ? " active" : ""}" data-dtab="prices">Prices</button>
Expand All @@ -365,7 +415,7 @@ function selectItem(itemId) {
<div id="arbitrage-container" class="arbitrage-container hidden"></div>
<div id="price-chart-container" class="price-chart-container hidden">
<div class="detail-grade-section-label">Price History</div>
<canvas id="price-chart" height="120"></canvas>
<canvas id="price-chart" height="140"></canvas>
<div id="price-chart-stats" class="price-chart-stats"></div>
</div>
</div>
Expand Down Expand Up @@ -412,14 +462,13 @@ async function loadCardIdentity(query) {
).join("");

const setName = card.setName || "";
container.innerHTML = `
<div class="card-id-row">
<span class="card-id-badge">${esc(card.cardId)}</span>
${card.rarity ? `<span class="card-id-rarity">${esc(card.rarity)}</span>` : ""}
${setName ? `<span class="card-id-set">${esc(setName)}</span>` : ""}
</div>
${nameHtml ? `<div class="card-id-names">${nameHtml}</div>` : ""}
`;
const parts = [
`<span class="card-id-badge">${esc(card.cardId)}</span>`,
card.rarity ? `<span class="card-id-rarity">${esc(card.rarity)}</span>` : "",
setName ? `<span class="card-id-set">${esc(setName)}</span>` : "",
nameHtml ? `<span class="card-id-sep"></span>${nameHtml}` : "",
].filter(Boolean).join("");
container.innerHTML = parts;
} catch {}
}

Expand All @@ -437,21 +486,31 @@ async function loadArbitrage(query) {
container.classList.remove("hidden");
const arb = data.arbitrage;

const sorted = names.sort((a, b) => sources[a].lowest - sources[b].lowest);
const savingsHtml = arb ? (() => {
const match = arb.summary.match(/\$[\d,.]+/);
const pctMatch = arb.summary.match(/(\d+%)\s*spread/);
const savings = match ? match[0] : "";
const spread = pctMatch ? pctMatch[1] : "";
return `<div class="arb-summary">${savings} cheaper on ${esc(arb.cheapest.source)}${spread ? `<span class="arb-summary-spread">${spread} spread</span>` : ""}</div>`;
})() : "";

container.innerHTML = `
<div class="detail-grade-section-label">Cross-Source Prices</div>
<div class="arbitrage-sources">
${names.sort((a, b) => sources[a].lowest - sources[b].lowest).map(s => {
${sorted.map(s => {
const d = sources[s];
const isCheapest = arb && s === arb.cheapest.source;
return `<div class="arb-source${isCheapest ? " arb-cheapest" : ""}">
<div class="arb-source-name">${esc(s)}</div>
<div class="arb-source-price">${formatPrice(d.lowest, d.currency)}</div>
${d.priceJPY ? `<div class="arb-source-jpy">¥${d.priceJPY.toLocaleString()}</div>` : ""}
<div class="arb-source-count">${d.count} listing${d.count !== 1 ? "s" : ""}</div>
${isCheapest ? `<span class="arb-best-chip">Best Price</span>` : ""}
</div>`;
}).join("")}
</div>
${arb ? `<div class="arb-summary">${esc(arb.summary)}</div>` : ""}
${savingsHtml}
`;
} catch {}
}
Expand Down Expand Up @@ -481,7 +540,7 @@ async function loadPriceChart(query) {
if (data.stats) {
statsEl.innerHTML = `
<span>Low: <b>${formatPrice(data.stats.min, "USD")}</b></span>
<span>Avg: <b>${formatPrice(data.stats.avg, "USD")}</b></span>
<span class="stat-avg">Avg: <b>${formatPrice(data.stats.avg, "USD")}</b></span>
<span>High: <b>${formatPrice(data.stats.max, "USD")}</b></span>
<span>${data.stats.count} sales</span>
`;
Expand All @@ -492,7 +551,7 @@ async function loadPriceChart(query) {
function drawPriceChart(canvas, points) {
const ctx = canvas.getContext("2d");
const w = canvas.parentElement.clientWidth;
const h = 120;
const h = 140;
canvas.width = w;
canvas.height = h;
canvas.style.width = w + "px";
Expand All @@ -502,7 +561,7 @@ function drawPriceChart(canvas, points) {
const max = Math.max(...prices) * 1.05;
const range = max - min || 1;

const pad = { top: 10, right: 10, bottom: 20, left: 50 };
const pad = { top: 10, right: 10, bottom: 32, left: 50 };
const cw = w - pad.left - pad.right;
const ch = h - pad.top - pad.bottom;

Expand Down Expand Up @@ -556,6 +615,19 @@ function drawPriceChart(canvas, points) {
ctx.arc(x, y, 3, 0, Math.PI * 2);
ctx.fill();
});

// X-axis date labels
const fmt = (d) => { const dt = new Date(d); return `${dt.getMonth() + 1}/${dt.getDate()}`; };
ctx.fillStyle = "rgba(138,138,154,0.6)";
ctx.font = "10px 'JetBrains Mono', monospace";
ctx.textAlign = "center";
const maxLabels = Math.min(points.length, 5);
const step = maxLabels > 1 ? (points.length - 1) / (maxLabels - 1) : 0;
for (let i = 0; i < maxLabels; i++) {
const idx = Math.round(i * step);
const x = pad.left + (idx / (points.length - 1 || 1)) * cw;
ctx.fillText(fmt(points[idx].recordedAt), x, h - 6);
}
}

function renderPsaInline(psa) {
Expand Down
4 changes: 4 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ <h1 class="fade-up">Research any Pokemon card<br>in seconds</h1>
<div class="list-tabs">
<button class="list-tab active" data-tab="active">Active Listings</button>
<button class="list-tab" data-tab="sold">Recent Sold</button>
<select id="sort-select" class="sort-select">
<option value="price-asc">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
</select>
</div>
<div id="active-list" class="card-list"></div>
<div id="sold-list" class="card-list hidden"></div>
Expand Down
Loading