feat: add Stalker products functionality with filtering, pagination, and purge option
This commit is contained in:
@@ -140,6 +140,33 @@ type StalkerResultsResponse = {
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type StalkerProductItem = {
|
||||
runId: number;
|
||||
started_at: string;
|
||||
seller_id: string;
|
||||
seller_name: string | null;
|
||||
rating: number | null;
|
||||
rating_count: number | null;
|
||||
asin: string;
|
||||
can_sell: number;
|
||||
sellability_status: string;
|
||||
sellability_reason: string | null;
|
||||
last_seen_at: string;
|
||||
};
|
||||
|
||||
type StalkerProductsResponse = {
|
||||
items: StalkerProductItem[];
|
||||
summary: {
|
||||
runs: number;
|
||||
sellers: number;
|
||||
products: number;
|
||||
};
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
type SortState = {
|
||||
field: string;
|
||||
direction: SortDirection;
|
||||
@@ -234,10 +261,12 @@ function Dashboard({
|
||||
onOpenRun,
|
||||
onOpenProducts,
|
||||
onOpenStalker,
|
||||
onOpenStalkerProducts,
|
||||
}: {
|
||||
onOpenRun: (run: Run) => void;
|
||||
onOpenProducts: (verdict: VerdictFilter) => void;
|
||||
onOpenStalker: () => void;
|
||||
onOpenStalkerProducts: () => void;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -328,7 +357,10 @@ function Dashboard({
|
||||
<div className="card">
|
||||
<div className="section-header">
|
||||
<h2>Runs Dashboard</h2>
|
||||
<button onClick={onOpenStalker}>Stalker results</button>
|
||||
<div className="button-row">
|
||||
<button onClick={onOpenStalker}>Stalker sellers</button>
|
||||
<button onClick={onOpenStalkerProducts}>Sellable products</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -889,9 +921,16 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
||||
);
|
||||
}
|
||||
|
||||
function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
||||
function StalkerExplorer({
|
||||
onBack,
|
||||
onOpenProducts,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onOpenProducts: () => void;
|
||||
}) {
|
||||
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
@@ -900,6 +939,7 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "persisted_inventory_asin_count", direction: "DESC" });
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -928,14 +968,41 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort]);
|
||||
}, [search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort, refreshTick]);
|
||||
|
||||
async function purgeStalkerData() {
|
||||
const confirmed = window.confirm("Permanently delete all Stalker runs, sellers, and sellable products from the database?");
|
||||
if (!confirmed) return;
|
||||
|
||||
setPurging(true);
|
||||
try {
|
||||
const res = await fetch("/api/stalker/purge", { method: "DELETE" });
|
||||
if (!res.ok) {
|
||||
const payload = await res.json().catch(() => null) as { error?: string } | null;
|
||||
window.alert(payload?.error ?? "Failed to purge Stalker data");
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
setRefreshTick((tick) => tick + 1);
|
||||
} finally {
|
||||
setPurging(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<button className="back" onClick={onBack}>Back</button>
|
||||
|
||||
<div className="card">
|
||||
<h2>Seller Storefronts</h2>
|
||||
<div className="section-header">
|
||||
<h2>Seller Storefronts</h2>
|
||||
<div className="button-row">
|
||||
<button onClick={onOpenProducts}>Sellable products</button>
|
||||
<button className="danger" disabled={purging} onClick={purgeStalkerData}>
|
||||
{purging ? "Purging..." : "Purge Stalker data"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="metrics">
|
||||
@@ -1032,11 +1099,142 @@ function StalkerExplorer({ onBack }: { onBack: () => void }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StalkerProductsExplorer({
|
||||
onBack,
|
||||
onOpenSellers,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onOpenSellers: () => void;
|
||||
}) {
|
||||
const [results, setResults] = useState<StalkerProductsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sort, setSort] = useState<SortState>({ field: "last_seen_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);
|
||||
|
||||
const res = await fetch(`/api/stalker/products?${params.toString()}`);
|
||||
const payload = (await res.json()) as StalkerProductsResponse;
|
||||
if (!cancelled) {
|
||||
setResults(payload);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search, sellerId, runId, page, pageSize, sort]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<button className="back" onClick={onBack}>Back</button>
|
||||
|
||||
<div className="card">
|
||||
<div className="section-header">
|
||||
<h2>Sellable Stalker Products</h2>
|
||||
<button onClick={onOpenSellers}>Seller storefronts</button>
|
||||
</div>
|
||||
</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">Sellers</div>
|
||||
<div className="value">{formatNumber(results?.summary.sellers)}</div>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<div className="label">Sellable products</div>
|
||||
<div className="value">{formatNumber(results?.summary.products)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="toolbar">
|
||||
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN or seller" />
|
||||
<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" />
|
||||
<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, "asin"))}>ASIN</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_count"))}>Rating Count</button></th>
|
||||
<th>Status</th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run</button></th>
|
||||
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={7}>Loading...</td></tr>
|
||||
) : results?.items.length ? (
|
||||
results.items.map((item) => (
|
||||
<tr key={`${item.runId}-${item.seller_id}-${item.asin}`}>
|
||||
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
||||
<td>{item.seller_id}</td>
|
||||
<td>{item.seller_name || "-"}</td>
|
||||
<td>{formatNumber(item.rating_count)}</td>
|
||||
<td><span className="badge badge-ok">{item.sellability_status}</span></td>
|
||||
<td>{item.runId}</td>
|
||||
<td>{formatDate(item.last_seen_at)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td colSpan={7}>No sellable Stalker products 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: "stalker" };
|
||||
| { kind: "stalker" }
|
||||
| { kind: "stalker-products" };
|
||||
|
||||
function parseRoute(pathname: string, search: string): AppRoute {
|
||||
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
|
||||
@@ -1055,6 +1253,10 @@ function parseRoute(pathname: string, search: string): AppRoute {
|
||||
return { kind: "stalker" };
|
||||
}
|
||||
|
||||
if (pathname === "/stalker/products") {
|
||||
return { kind: "stalker-products" };
|
||||
}
|
||||
|
||||
return { kind: "dashboard" };
|
||||
}
|
||||
|
||||
@@ -1084,6 +1286,11 @@ function App() {
|
||||
setRoute({ kind: "stalker" });
|
||||
}
|
||||
|
||||
function openStalkerProducts() {
|
||||
history.pushState({}, "", "/stalker/products");
|
||||
setRoute({ kind: "stalker-products" });
|
||||
}
|
||||
|
||||
function backToDashboard() {
|
||||
history.pushState({}, "", "/");
|
||||
setRoute({ kind: "dashboard" });
|
||||
@@ -1098,10 +1305,21 @@ function App() {
|
||||
}
|
||||
|
||||
if (route.kind === "stalker") {
|
||||
return <StalkerExplorer onBack={backToDashboard} />;
|
||||
return <StalkerExplorer onBack={backToDashboard} onOpenProducts={openStalkerProducts} />;
|
||||
}
|
||||
|
||||
return <Dashboard onOpenRun={openRun} onOpenProducts={openProducts} onOpenStalker={openStalker} />;
|
||||
if (route.kind === "stalker-products") {
|
||||
return <StalkerProductsExplorer onBack={backToDashboard} onOpenSellers={openStalker} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dashboard
|
||||
onOpenRun={openRun}
|
||||
onOpenProducts={openProducts}
|
||||
onOpenStalker={openStalker}
|
||||
onOpenStalkerProducts={openStalkerProducts}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
Reference in New Issue
Block a user