feat: add Stalker results page with filtering and pagination

- Introduced StalkerResultItem and StalkerResultsResponse types for handling API responses.
- Implemented StalkerExplorer component for displaying Stalker results with search and filter options.
- Added sorting functionality for Stalker results table.
- Enhanced Dashboard to include a button for navigating to Stalker results.
- Updated routing to support Stalker results page.
- Improved styles for section headers and inventory columns in the results table.
This commit is contained in:
Victor Noguera
2026-05-19 18:10:01 -04:00
parent 0f9b785cce
commit a7c0e44e3d
7 changed files with 2037 additions and 3 deletions

View File

@@ -109,6 +109,45 @@ type ProductListResponse = {
totalPages: number;
};
type StalkerResultItem = {
runId: number;
started_at: string;
status: string;
input_file: string;
source_asin: string;
title: string | null;
offer_count: number;
candidate_seller_count: number;
matched_seller_count: number;
scan_fetched_at: string;
seller_id: string;
seller_name: string | null;
rating: number | null;
rating_count: number | null;
storefront_asin_total: number | null;
persisted_inventory_sample_count: number | null;
offer_price: number | null;
condition: string | null;
is_fba: number | null;
stock: number | null;
persisted_inventory_asin_count: number;
inventory_sample_asins: string | null;
};
type StalkerResultsResponse = {
items: StalkerResultItem[];
summary: {
runs: number;
sourceAsins: number;
sellers: number;
persistedInventoryAsins: number;
};
page: number;
pageSize: number;
total: number;
totalPages: number;
};
type SortState = {
field: string;
direction: SortDirection;
@@ -139,6 +178,11 @@ function formatAmazonSeller(value: number | null | undefined): string {
return value === 1 ? "Yes" : "No";
}
function formatBoolean(value: number | null | undefined): string {
if (value === null || value === undefined) return "-";
return value === 1 ? "Yes" : "No";
}
function buildSortValue(sort: SortState): string {
return `${sort.field}:${sort.direction}`;
}
@@ -197,9 +241,11 @@ function detectAnomaly(item: ResultItem): string {
function Dashboard({
onOpenRun,
onOpenProducts,
onOpenStalker,
}: {
onOpenRun: (run: Run) => void;
onOpenProducts: (verdict: VerdictFilter) => void;
onOpenStalker: () => void;
}) {
const [runs, setRuns] = useState<RunsResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -288,7 +334,10 @@ function Dashboard({
return (
<div className="page">
<div className="card">
<h2>Runs Dashboard</h2>
<div className="section-header">
<h2>Runs Dashboard</h2>
<button onClick={onOpenStalker}>Stalker results</button>
</div>
</div>
<div className="metrics">
@@ -848,10 +897,166 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
);
}
function StalkerExplorer({ onBack }: { onBack: () => void }) {
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [sellerId, setSellerId] = useState("");
const [runId, setRunId] = useState("");
const [minRatingCount, setMinRatingCount] = useState("1");
const [maxRatingCount, setMaxRatingCount] = useState("30");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sort, setSort] = useState<SortState>({ field: "started_at", direction: "DESC" });
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
sort: buildSortValue(sort),
});
if (search) params.set("q", search);
if (sellerId) params.set("sellerId", sellerId);
if (runId) params.set("runId", runId);
if (minRatingCount) params.set("minRatingCount", minRatingCount);
if (maxRatingCount) params.set("maxRatingCount", maxRatingCount);
const res = await fetch(`/api/stalker/results?${params.toString()}`);
const payload = (await res.json()) as StalkerResultsResponse;
if (!cancelled) {
setResults(payload);
setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort]);
return (
<div className="page">
<button className="back" onClick={onBack}>Back</button>
<div className="card">
<h2>Stalker Results</h2>
</div>
<div className="metrics">
<div className="metric">
<div className="label">Runs</div>
<div className="value">{formatNumber(results?.summary.runs)}</div>
</div>
<div className="metric">
<div className="label">Source ASINs</div>
<div className="value">{formatNumber(results?.summary.sourceAsins)}</div>
</div>
<div className="metric">
<div className="label">Matched sellers</div>
<div className="value">{formatNumber(results?.summary.sellers)}</div>
</div>
<div className="metric">
<div className="label">Persisted inventory ASINs</div>
<div className="value">{formatNumber(results?.summary.persistedInventoryAsins)}</div>
</div>
</div>
<div className="card">
<div className="toolbar">
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search source ASIN/title/seller/inventory" />
<input value={sellerId} onChange={(e) => { setPage(1); setSellerId(e.target.value.toUpperCase()); }} placeholder="Seller ID" />
<input value={runId} onChange={(e) => { setPage(1); setRunId(e.target.value); }} placeholder="Run ID" />
<input value={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min rating count" />
<input value={maxRatingCount} onChange={(e) => { setPage(1); setMaxRatingCount(e.target.value); }} placeholder="Max rating count" />
<select value={String(pageSize)} onChange={(e) => { setPage(1); setPageSize(Number(e.target.value)); }}>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
</div>
</div>
<div className="card">
<div className="table-wrap">
<table className="stalker-table">
<thead>
<tr>
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
<th><button onClick={() => setSort(nextSort(sort, "started_at"))}>Started</button></th>
<th><button onClick={() => setSort(nextSort(sort, "source_asin"))}>Source ASIN</button></th>
<th><button onClick={() => setSort(nextSort(sort, "title"))}>Title</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_id"))}>Seller ID</button></th>
<th><button onClick={() => setSort(nextSort(sort, "seller_name"))}>Seller</button></th>
<th><button onClick={() => setSort(nextSort(sort, "rating"))}>Rating</button></th>
<th><button onClick={() => setSort(nextSort(sort, "rating_count"))}>Rating Count</button></th>
<th><button onClick={() => setSort(nextSort(sort, "offer_price"))}>Offer</button></th>
<th>FBA</th>
<th><button onClick={() => setSort(nextSort(sort, "stock"))}>Stock</button></th>
<th><button onClick={() => setSort(nextSort(sort, "storefront_asin_total"))}>Storefront total</button></th>
<th><button onClick={() => setSort(nextSort(sort, "persisted_inventory_asin_count"))}>Persisted sample</button></th>
<th className="inventory-col">Inventory ASIN sample</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={14}>Loading...</td></tr>
) : results?.items.length ? (
results.items.map((item) => {
const inventorySample = (item.inventory_sample_asins ?? "")
.split(",")
.filter(Boolean)
.slice(0, 20);
return (
<tr key={`${item.runId}-${item.source_asin}-${item.seller_id}`}>
<td>{item.runId}</td>
<td>{formatDate(item.started_at)}</td>
<td><a href={`http://amazon.com/dp/${item.source_asin}`} target="_blank" rel="noreferrer">{item.source_asin}</a></td>
<td className="product-col">{item.title || "-"}</td>
<td>{item.seller_id}</td>
<td>{item.seller_name || "-"}</td>
<td>{formatNumber(item.rating)}</td>
<td>{formatNumber(item.rating_count)}</td>
<td>{formatCurrency(item.offer_price)}</td>
<td>{formatBoolean(item.is_fba)}</td>
<td>{formatNumber(item.stock)}</td>
<td>{formatNumber(item.storefront_asin_total)}</td>
<td>{formatNumber(item.persisted_inventory_asin_count)}</td>
<td className="inventory-col">
{inventorySample.length === 0 ? "-" : inventorySample.map((asin) => (
<a key={asin} href={`http://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a>
))}
</td>
</tr>
);
})
) : (
<tr><td colSpan={14}>No stalker results found</td></tr>
)}
</tbody>
</table>
</div>
<div className="pager">
<div>Showing {results?.items.length ?? 0} of {results?.total ?? 0}</div>
<div>
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
<span style={{ padding: "0 8px" }}>Page {results?.page ?? page} / {results?.totalPages ?? 1}</span>
<button disabled={Boolean(results && page >= results.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
</div>
</div>
</div>
</div>
);
}
type AppRoute =
| { kind: "dashboard" }
| { kind: "run"; processType: ProcessType; runId: number }
| { kind: "products"; verdict: VerdictFilter };
| { kind: "products"; verdict: VerdictFilter }
| { kind: "stalker" };
function parseRoute(pathname: string, search: string): AppRoute {
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
@@ -866,6 +1071,10 @@ function parseRoute(pathname: string, search: string): AppRoute {
return { kind: "products", verdict };
}
if (pathname === "/stalker") {
return { kind: "stalker" };
}
return { kind: "dashboard" };
}
@@ -890,6 +1099,11 @@ function App() {
setRoute({ kind: "products", verdict });
}
function openStalker() {
history.pushState({}, "", "/stalker");
setRoute({ kind: "stalker" });
}
function backToDashboard() {
history.pushState({}, "", "/");
setRoute({ kind: "dashboard" });
@@ -903,7 +1117,11 @@ function App() {
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
}
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} />;
if (route.kind === "stalker") {
return <StalkerExplorer onBack={backToDashboard} />;
}
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} onOpenStalker={openStalker} />;
}
const root = document.getElementById("root");