1486 lines
60 KiB
TypeScript
1486 lines
60 KiB
TypeScript
import { createRoot } from "react-dom/client";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
type ProcessType = "lead_analysis" | "category_analysis";
|
|
type SortDirection = "ASC" | "DESC";
|
|
|
|
type Run = {
|
|
processType: ProcessType;
|
|
runId: number;
|
|
timestamp: string;
|
|
status: string;
|
|
jobType: string;
|
|
source: string | null;
|
|
output: string | null;
|
|
totalProducts: number;
|
|
fbaCount: number;
|
|
fbmCount: number;
|
|
skipCount: number;
|
|
};
|
|
|
|
type RunsResponse = {
|
|
items: Run[];
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
|
|
type RunDetail = {
|
|
processType: ProcessType;
|
|
runId: number;
|
|
timestamp: string;
|
|
status: string;
|
|
jobType: string;
|
|
source: string | null;
|
|
output: string | null;
|
|
totalProducts: number;
|
|
fbaCount: number;
|
|
fbmCount: number;
|
|
skipCount: number;
|
|
summary: {
|
|
totalProducts: number;
|
|
fbaCount: number;
|
|
fbmCount: number;
|
|
skipCount: number;
|
|
};
|
|
errorMessage?: string;
|
|
availableAsins?: number;
|
|
};
|
|
|
|
type ResultItem = {
|
|
id?: number;
|
|
run_id: number;
|
|
asin: string;
|
|
product_name: string | null;
|
|
brand: string | null;
|
|
category: string | null;
|
|
current_price: number | null;
|
|
avg_price_90d: number | null;
|
|
sales_rank: number | null;
|
|
seller_count: number | null;
|
|
monthly_sold: number | null;
|
|
verdict: "FBA" | "FBM" | "SKIP";
|
|
amazon_is_seller: number | null;
|
|
amazon_buybox_share_pct_90d: number | null;
|
|
confidence: number | null;
|
|
sellability_status: string | null;
|
|
reasoning: string | null;
|
|
fetched_at: string;
|
|
};
|
|
|
|
type ResultsResponse = {
|
|
items: ResultItem[];
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
|
|
type VerdictFilter = "" | "FBA" | "FBM" | "SKIP";
|
|
type AmazonSellerFilter = "" | "yes" | "no";
|
|
|
|
type ProductListItem = {
|
|
processType: ProcessType;
|
|
runId: number;
|
|
asin: string;
|
|
product_name: string | null;
|
|
brand: string | null;
|
|
category: string | null;
|
|
verdict: "FBA" | "FBM" | "SKIP";
|
|
confidence: number | null;
|
|
sellability_status: string | null;
|
|
monthly_sold: number | null;
|
|
seller_count: number | null;
|
|
amazon_is_seller: number | null;
|
|
amazon_buybox_share_pct_90d: number | null;
|
|
sales_rank: number | null;
|
|
current_price: number | null;
|
|
avg_price_90d: number | null;
|
|
reasoning: string | null;
|
|
fetched_at: string;
|
|
};
|
|
|
|
type ProductListResponse = {
|
|
items: ProductListItem[];
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
|
|
type StalkerResultItem = {
|
|
runId: number;
|
|
started_at: string;
|
|
status: string;
|
|
input_file: 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;
|
|
discovered_from_count: number;
|
|
first_seen_at: string;
|
|
last_seen_at: string;
|
|
persisted_inventory_asin_count: number;
|
|
inventory_sample_asins: string | null;
|
|
};
|
|
|
|
type StalkerResultsResponse = {
|
|
items: StalkerResultItem[];
|
|
summary: {
|
|
runs: number;
|
|
sellers: number;
|
|
persistedInventoryAsins: number;
|
|
};
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
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;
|
|
product_title: string | null;
|
|
brand: string | null;
|
|
category_tree: string | null;
|
|
current_price: number | null;
|
|
avg_price_90d: number | null;
|
|
sales_rank: number | null;
|
|
monthly_sold: number | null;
|
|
seller_count: number | null;
|
|
amazon_is_seller: number | null;
|
|
verdict: "FBA" | "FBM" | "SKIP" | null;
|
|
confidence: number | null;
|
|
reasoning: 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;
|
|
};
|
|
|
|
function formatDate(input: string): string {
|
|
const d = new Date(input);
|
|
if (Number.isNaN(d.getTime())) return input;
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function formatNumber(value: number | null | undefined): string {
|
|
if (value === null || value === undefined) return "-";
|
|
return new Intl.NumberFormat().format(value);
|
|
}
|
|
|
|
function formatCurrency(value: number | null | undefined): string {
|
|
if (value === null || value === undefined) return "-";
|
|
return new Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency: "USD",
|
|
maximumFractionDigits: 2,
|
|
}).format(value);
|
|
}
|
|
|
|
function formatAmazonSeller(value: number | null | undefined): string {
|
|
if (value === null || value === undefined) return "-";
|
|
return value === 1 ? "Yes" : "No";
|
|
}
|
|
|
|
function formatBoolean(value: number | null | undefined): string {
|
|
if (value === null || value === undefined) return "-";
|
|
return value === 1 ? "Yes" : "No";
|
|
}
|
|
|
|
function parseStringArrayJson(value: string | null | undefined): string[] {
|
|
if (!value) return [];
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed)
|
|
? parsed.filter((item): item is string => typeof item === "string")
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function buildSortValue(sort: SortState): string {
|
|
return `${sort.field}:${sort.direction}`;
|
|
}
|
|
|
|
function nextSort(current: SortState, field: string): SortState {
|
|
if (current.field !== field) {
|
|
return { field, direction: "ASC" };
|
|
}
|
|
return {
|
|
field,
|
|
direction: current.direction === "ASC" ? "DESC" : "ASC",
|
|
};
|
|
}
|
|
|
|
function statusBadgeClass(status: string): string {
|
|
if (status === "ok" || status === "completed") return "badge badge-ok";
|
|
if (status === "failed") return "badge badge-failed";
|
|
return "badge badge-empty";
|
|
}
|
|
|
|
function verdictBadgeClass(verdict: string): string {
|
|
if (verdict === "FBA") return "badge badge-fba";
|
|
if (verdict === "FBM") return "badge badge-fbm";
|
|
return "badge badge-skip";
|
|
}
|
|
|
|
function TinyBar({ fba, fbm, skip }: { fba: number; fbm: number; skip: number }) {
|
|
const total = Math.max(1, fba + fbm + skip);
|
|
const fbaPct = (fba / total) * 100;
|
|
const fbmPct = (fbm / total) * 100;
|
|
const skipPct = (skip / total) * 100;
|
|
|
|
return (
|
|
<div className="tiny-bar" title={`FBA ${fba}, FBM ${fbm}, SKIP ${skip}`}>
|
|
<span className="tiny-fba" style={{ width: `${fbaPct}%` }} />
|
|
<span className="tiny-fbm" style={{ width: `${fbmPct}%` }} />
|
|
<span className="tiny-skip" style={{ width: `${skipPct}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function detectAnomaly(item: ResultItem): string {
|
|
const confidence = item.confidence ?? 0;
|
|
if ((item.sellability_status === "restricted" || item.sellability_status === "not_available") && item.verdict !== "SKIP") {
|
|
return "restricted-vs-verdict";
|
|
}
|
|
if (item.verdict === "FBA" && confidence < 60) {
|
|
return "low-confidence-fba";
|
|
}
|
|
if ((item.sales_rank ?? Number.MAX_SAFE_INTEGER) > 300000 && item.verdict === "FBA") {
|
|
return "high-rank-fba";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
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);
|
|
const [search, setSearch] = useState("");
|
|
const [processType, setProcessType] = useState("");
|
|
const [status, setStatus] = useState("");
|
|
const [startDate, setStartDate] = useState("");
|
|
const [endDate, setEndDate] = useState("");
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(25);
|
|
const [sort, setSort] = useState<SortState>({ field: "timestamp", direction: "DESC" });
|
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
|
|
|
const summary = useMemo(() => {
|
|
if (!runs) return { total: 0, fba: 0, fbm: 0, skip: 0 };
|
|
return runs.items.reduce(
|
|
(acc, run) => {
|
|
acc.total += run.totalProducts;
|
|
acc.fba += run.fbaCount;
|
|
acc.fbm += run.fbmCount;
|
|
acc.skip += run.skipCount;
|
|
return acc;
|
|
},
|
|
{ total: 0, fba: 0, fbm: 0, skip: 0 },
|
|
);
|
|
}, [runs]);
|
|
|
|
const timeline = useMemo(() => {
|
|
if (!runs) return [] as Run[];
|
|
return [...runs.items]
|
|
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
|
|
.slice(-12);
|
|
}, [runs]);
|
|
|
|
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 (processType) params.set("processType", processType);
|
|
if (status) params.set("status", status);
|
|
if (startDate) params.set("startDate", `${startDate}T00:00:00.000Z`);
|
|
if (endDate) params.set("endDate", `${endDate}T23:59:59.999Z`);
|
|
|
|
const resp = await fetch(`/api/runs?${params.toString()}`);
|
|
const data = (await resp.json()) as RunsResponse;
|
|
if (!cancelled) {
|
|
setRuns(data);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [search, processType, status, startDate, endDate, page, pageSize, sort, refreshTick]);
|
|
|
|
async function deleteRun(run: Run) {
|
|
const key = `${run.processType}-${run.runId}`;
|
|
const confirmed = window.confirm(`Delete run ${run.runId} (${run.processType}) and all associated results?`);
|
|
if (!confirmed) return;
|
|
|
|
setDeletingKey(key);
|
|
try {
|
|
const response = await fetch(`/api/runs/${run.processType}/${run.runId}`, { method: "DELETE" });
|
|
if (!response.ok) {
|
|
const errorPayload = await response.json().catch(() => null) as { error?: string } | null;
|
|
const message = errorPayload?.error ?? "Failed to delete run";
|
|
window.alert(message);
|
|
return;
|
|
}
|
|
setPage(1);
|
|
setRefreshTick((tick) => tick + 1);
|
|
} finally {
|
|
setDeletingKey(null);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="card">
|
|
<div className="section-header">
|
|
<h2>Runs Dashboard</h2>
|
|
<div className="button-row">
|
|
<button onClick={onOpenStalker}>Stalker sellers</button>
|
|
<button onClick={onOpenStalkerProducts}>Sellable products</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="metrics">
|
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts(""); }}>
|
|
<div className="label">Total products</div>
|
|
<div className="value">{formatNumber(summary.total)}</div>
|
|
</div>
|
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("FBA")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("FBA"); }}>
|
|
<div className="label">FBA</div>
|
|
<div className="value">{formatNumber(summary.fba)}</div>
|
|
</div>
|
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("FBM")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("FBM"); }}>
|
|
<div className="label">FBM</div>
|
|
<div className="value">{formatNumber(summary.fbm)}</div>
|
|
</div>
|
|
<div className="metric" role="button" tabIndex={0} onClick={() => onOpenProducts("SKIP")} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onOpenProducts("SKIP"); }}>
|
|
<div className="label">SKIP</div>
|
|
<div className="value">{formatNumber(summary.skip)}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="toolbar">
|
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search run/job/source" />
|
|
<select value={processType} onChange={(e) => { setPage(1); setProcessType(e.target.value); }}>
|
|
<option value="">All processes</option>
|
|
<option value="lead_analysis">lead_analysis</option>
|
|
<option value="category_analysis">category_analysis</option>
|
|
</select>
|
|
<select value={status} onChange={(e) => { setPage(1); setStatus(e.target.value); }}>
|
|
<option value="">All statuses</option>
|
|
<option value="completed">completed</option>
|
|
<option value="ok">ok</option>
|
|
<option value="empty">empty</option>
|
|
<option value="failed">failed</option>
|
|
</select>
|
|
<input type="date" value={startDate} onChange={(e) => { setPage(1); setStartDate(e.target.value); }} />
|
|
<input type="date" value={endDate} onChange={(e) => { setPage(1); setEndDate(e.target.value); }} />
|
|
<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>
|
|
<thead>
|
|
<tr>
|
|
<th><button onClick={() => setSort(nextSort(sort, "runId"))}>Run ID</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "processType"))}>Process</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "jobType"))}>Job Type</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "timestamp"))}>Timestamp</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "status"))}>Status</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "totalProducts"))}>Total</button></th>
|
|
<th>FBA</th>
|
|
<th>FBM</th>
|
|
<th>SKIP</th>
|
|
<th>Mix</th>
|
|
<th>Source</th>
|
|
<th>Open</th>
|
|
<th>Delete</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={13}>Loading...</td></tr>
|
|
) : runs?.items.length ? (
|
|
runs.items.map((run) => (
|
|
<tr key={`${run.processType}-${run.runId}`}>
|
|
<td>{run.runId}</td>
|
|
<td>{run.processType}</td>
|
|
<td>{run.jobType}</td>
|
|
<td>{formatDate(run.timestamp)}</td>
|
|
<td><span className={statusBadgeClass(run.status)}>{run.status}</span></td>
|
|
<td>{formatNumber(run.totalProducts)}</td>
|
|
<td>{formatNumber(run.fbaCount)}</td>
|
|
<td>{formatNumber(run.fbmCount)}</td>
|
|
<td>{formatNumber(run.skipCount)}</td>
|
|
<td><TinyBar fba={run.fbaCount} fbm={run.fbmCount} skip={run.skipCount} /></td>
|
|
<td>{run.source || "-"}</td>
|
|
<td><button onClick={() => onOpenRun(run)}>View</button></td>
|
|
<td>
|
|
<button disabled={deletingKey === `${run.processType}-${run.runId}`} onClick={() => deleteRun(run)}>
|
|
{deletingKey === `${run.processType}-${run.runId}` ? "Deleting..." : "Delete"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr><td colSpan={13}>No runs found</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="pager">
|
|
<div>Showing {runs?.items.length ?? 0} of {runs?.total ?? 0}</div>
|
|
<div>
|
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
|
|
<span style={{ padding: "0 8px" }}>Page {runs?.page ?? page} / {runs?.totalPages ?? 1}</span>
|
|
<button disabled={Boolean(runs && page >= runs.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3>Recent trend (last 12 runs in current view)</h3>
|
|
<div className="spark-grid" style={{ marginTop: 10 }}>
|
|
{timeline.length === 0 ? (
|
|
<div className="meta">No trend data</div>
|
|
) : (
|
|
timeline.map((run) => (
|
|
<div key={`trend-${run.processType}-${run.runId}`} className="spark-item" title={`${run.runId} • ${formatDate(run.timestamp)}`}>
|
|
<div className="spark-label">#{run.runId}</div>
|
|
<TinyBar fba={run.fbaCount} fbm={run.fbmCount} skip={run.skipCount} />
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RunDetails({
|
|
processType,
|
|
runId,
|
|
onBack,
|
|
}: {
|
|
processType: ProcessType;
|
|
runId: number;
|
|
onBack: () => void;
|
|
}) {
|
|
const [run, setRun] = useState<RunDetail | null>(null);
|
|
const [results, setResults] = useState<ResultsResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [verdict, setVerdict] = useState("");
|
|
const [sellabilityStatus, setSellabilityStatus] = useState("");
|
|
const [amazonSellerFilter, setAmazonSellerFilter] =
|
|
useState<AmazonSellerFilter>("");
|
|
const [minConfidence, setMinConfidence] = useState("");
|
|
const [maxConfidence, setMaxConfidence] = useState("");
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(25);
|
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
|
|
|
const anomalies = useMemo(() => {
|
|
if (!results) return [] as ResultItem[];
|
|
return results.items.filter((item) => detectAnomaly(item) !== "");
|
|
}, [results]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadRun() {
|
|
const res = await fetch(`/api/runs/${processType}/${runId}`);
|
|
const payload = (await res.json()) as RunDetail;
|
|
if (!cancelled) {
|
|
setRun(payload);
|
|
}
|
|
}
|
|
loadRun();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [processType, runId, refreshTick]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadResults() {
|
|
setLoading(true);
|
|
const params = new URLSearchParams({
|
|
page: String(page),
|
|
pageSize: String(pageSize),
|
|
sort: buildSortValue(sort),
|
|
});
|
|
if (search) params.set("q", search);
|
|
if (verdict) params.set("verdict", verdict);
|
|
if (sellabilityStatus) params.set("sellabilityStatus", sellabilityStatus);
|
|
if (amazonSellerFilter) params.set("amazonIsSeller", amazonSellerFilter);
|
|
if (minConfidence) params.set("minConfidence", minConfidence);
|
|
if (maxConfidence) params.set("maxConfidence", maxConfidence);
|
|
|
|
const res = await fetch(`/api/runs/${processType}/${runId}/results?${params.toString()}`);
|
|
const payload = (await res.json()) as ResultsResponse;
|
|
if (!cancelled) {
|
|
setResults(payload);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
loadResults();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [processType, runId, search, verdict, sellabilityStatus, amazonSellerFilter, minConfidence, maxConfidence, page, pageSize, sort, refreshTick]);
|
|
|
|
useEffect(() => {
|
|
const interval = window.setInterval(() => {
|
|
setRefreshTick((tick) => tick + 1);
|
|
}, 4000);
|
|
return () => {
|
|
window.clearInterval(interval);
|
|
};
|
|
}, [processType, runId]);
|
|
|
|
async function reanalyzeAsin(asin: string) {
|
|
if (reanalyzing[asin]) return;
|
|
setReanalyzing((prev) => ({ ...prev, [asin]: true }));
|
|
try {
|
|
const response = await fetch(
|
|
`/api/runs/${processType}/${runId}/asins/${encodeURIComponent(asin)}/reanalyze`,
|
|
{ method: "POST" },
|
|
);
|
|
if (!response.ok) {
|
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
window.alert(payload?.error ?? "Failed to re-analyze ASIN");
|
|
return;
|
|
}
|
|
setRefreshTick((tick) => tick + 1);
|
|
} finally {
|
|
setReanalyzing((prev) => {
|
|
const next = { ...prev };
|
|
delete next[asin];
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<button className="back" onClick={onBack}>Back</button>
|
|
|
|
<div className="card">
|
|
<h2>Run Detail</h2>
|
|
<div className="meta-grid" style={{ marginTop: 12 }}>
|
|
<div className="meta"><strong>Process:</strong> {processType}</div>
|
|
<div className="meta"><strong>Run ID:</strong> {runId}</div>
|
|
<div className="meta"><strong>Status:</strong> {run ? <span className={statusBadgeClass(run.status)}>{run.status}</span> : "-"}</div>
|
|
<div className="meta"><strong>Timestamp:</strong> {run ? formatDate(run.timestamp) : "-"}</div>
|
|
<div className="meta"><strong>Job:</strong> {run?.jobType ?? "-"}</div>
|
|
<div className="meta"><strong>Source:</strong> {run?.source ?? "-"}</div>
|
|
<div className="meta"><strong>Total:</strong> {formatNumber(run?.summary.totalProducts)}</div>
|
|
<div className="meta"><strong>FBA/FBM/SKIP:</strong> {formatNumber(run?.summary.fbaCount)}/{formatNumber(run?.summary.fbmCount)}/{formatNumber(run?.summary.skipCount)}</div>
|
|
</div>
|
|
<div style={{ marginTop: 10 }}>
|
|
<TinyBar fba={run?.summary.fbaCount ?? 0} fbm={run?.summary.fbmCount ?? 0} skip={run?.summary.skipCount ?? 0} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="toolbar">
|
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN/name/brand/category/reason" />
|
|
<select value={verdict} onChange={(e) => { setPage(1); setVerdict(e.target.value); }}>
|
|
<option value="">All verdicts</option>
|
|
<option value="FBA">FBA</option>
|
|
<option value="FBM">FBM</option>
|
|
<option value="SKIP">SKIP</option>
|
|
</select>
|
|
<select value={sellabilityStatus} onChange={(e) => { setPage(1); setSellabilityStatus(e.target.value); }}>
|
|
<option value="">All sellability</option>
|
|
<option value="available">available</option>
|
|
<option value="restricted">restricted</option>
|
|
<option value="not_available">not_available</option>
|
|
<option value="unknown">unknown</option>
|
|
</select>
|
|
<select
|
|
value={amazonSellerFilter}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setAmazonSellerFilter(e.target.value as AmazonSellerFilter);
|
|
}}
|
|
>
|
|
<option value="">Amazon seller: all</option>
|
|
<option value="yes">Amazon seller: yes</option>
|
|
<option value="no">Amazon seller: no</option>
|
|
</select>
|
|
<input value={minConfidence} onChange={(e) => { setPage(1); setMinConfidence(e.target.value); }} placeholder="Min confidence" />
|
|
<input value={maxConfidence} onChange={(e) => { setPage(1); setMaxConfidence(e.target.value); }} placeholder="Max confidence" />
|
|
<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 style={{ marginTop: 10 }}>
|
|
<a
|
|
href={`/api/runs/${processType}/${runId}/export.csv?q=${encodeURIComponent(search)}&verdict=${encodeURIComponent(verdict)}&sellabilityStatus=${encodeURIComponent(sellabilityStatus)}&amazonIsSeller=${encodeURIComponent(amazonSellerFilter)}&minConfidence=${encodeURIComponent(minConfidence)}&maxConfidence=${encodeURIComponent(maxConfidence)}&sort=${encodeURIComponent(buildSortValue(sort))}`}
|
|
>
|
|
<button>Export filtered CSV</button>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3>Anomalies in current page</h3>
|
|
{anomalies.length === 0 ? (
|
|
<div className="meta" style={{ marginTop: 8 }}>No anomalies detected with current heuristic.</div>
|
|
) : (
|
|
<div className="anomaly-list" style={{ marginTop: 8 }}>
|
|
{anomalies.slice(0, 8).map((item) => (
|
|
<div key={`anom-${item.asin}-${item.fetched_at}`} className="anomaly-item">
|
|
<a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a>
|
|
<span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span>
|
|
<span>{detectAnomaly(item)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "category"))}>Category</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
|
|
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={15}>Loading...</td></tr>
|
|
) : results?.items.length ? (
|
|
results.items.map((item) => (
|
|
<tr key={`${item.asin}-${item.fetched_at}`}>
|
|
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
|
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
|
<td>{formatNumber(item.monthly_sold)}</td>
|
|
<td>{formatNumber(item.seller_count)}</td>
|
|
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
|
|
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
|
|
<td>{formatNumber(item.sales_rank)}</td>
|
|
<td>{formatCurrency(item.current_price)}</td>
|
|
<td title={item.reasoning || undefined}>{item.product_name || "-"}</td>
|
|
<td>{item.brand || "-"}</td>
|
|
<td>{item.category || "-"}</td>
|
|
<td>{formatCurrency(item.avg_price_90d)}</td>
|
|
<td>{formatNumber(item.confidence)}</td>
|
|
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
|
|
<td>
|
|
<button
|
|
onClick={() => reanalyzeAsin(item.asin)}
|
|
disabled={Boolean(reanalyzing[item.asin])}
|
|
>
|
|
{reanalyzing[item.asin] ? "Re-analyzing..." : "Re-analyze"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr><td colSpan={15}>No 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>
|
|
);
|
|
}
|
|
|
|
function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () => void }) {
|
|
const [items, setItems] = useState<ProductListResponse | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
|
|
const [amazonSellerFilter, setAmazonSellerFilter] =
|
|
useState<AmazonSellerFilter>("");
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(25);
|
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
|
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
|
|
|
useEffect(() => {
|
|
setActiveVerdict(verdict);
|
|
setPage(1);
|
|
}, [verdict]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function load() {
|
|
setLoading(true);
|
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
params.set("sort", buildSortValue(sort));
|
|
if (search) params.set("q", search);
|
|
if (activeVerdict) params.set("verdict", activeVerdict);
|
|
if (amazonSellerFilter) params.set("amazonIsSeller", amazonSellerFilter);
|
|
const res = await fetch(`/api/products?${params.toString()}`);
|
|
const payload = (await res.json()) as ProductListResponse;
|
|
if (!cancelled) {
|
|
setItems(payload);
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
load();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [search, activeVerdict, amazonSellerFilter, page, pageSize, sort]);
|
|
|
|
async function reanalyzeAsin(item: ProductListItem) {
|
|
const key = `${item.processType}:${item.runId}:${item.asin}`;
|
|
if (reanalyzing[key]) return;
|
|
setReanalyzing((prev) => ({ ...prev, [key]: true }));
|
|
try {
|
|
const response = await fetch(
|
|
`/api/runs/${item.processType}/${item.runId}/asins/${encodeURIComponent(item.asin)}/reanalyze`,
|
|
{ method: "POST" },
|
|
);
|
|
if (!response.ok) {
|
|
const payload = (await response.json().catch(() => null)) as { error?: string } | null;
|
|
window.alert(payload?.error ?? "Failed to re-analyze ASIN");
|
|
return;
|
|
}
|
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
|
params.set("sort", buildSortValue(sort));
|
|
if (search) params.set("q", search);
|
|
if (activeVerdict) params.set("verdict", activeVerdict);
|
|
if (amazonSellerFilter) params.set("amazonIsSeller", amazonSellerFilter);
|
|
const res = await fetch(`/api/products?${params.toString()}`);
|
|
const payload = (await res.json()) as ProductListResponse;
|
|
setItems(payload);
|
|
} finally {
|
|
setReanalyzing((prev) => {
|
|
const next = { ...prev };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<button className="back" onClick={onBack}>Back</button>
|
|
<div className="card">
|
|
<h2>Products</h2>
|
|
<div className="toolbar" style={{ marginTop: 10 }}>
|
|
<input value={search} onChange={(e) => { setPage(1); setSearch(e.target.value); }} placeholder="Search ASIN/name/brand/category" />
|
|
<select value={activeVerdict} onChange={(e) => { setPage(1); setActiveVerdict(e.target.value as VerdictFilter); }}>
|
|
<option value="">All verdicts</option>
|
|
<option value="FBA">FBA</option>
|
|
<option value="FBM">FBM</option>
|
|
<option value="SKIP">SKIP</option>
|
|
</select>
|
|
<select
|
|
value={amazonSellerFilter}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setAmazonSellerFilter(e.target.value as AmazonSellerFilter);
|
|
}}
|
|
>
|
|
<option value="">Amazon seller: all</option>
|
|
<option value="yes">Amazon seller: yes</option>
|
|
<option value="no">Amazon seller: no</option>
|
|
</select>
|
|
<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>
|
|
<thead>
|
|
<tr>
|
|
<th><button onClick={() => setSort(nextSort(sort, "asin"))}>ASIN</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "amazon_buybox_share_pct_90d"))}>Amazon Buy Box 90d %</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
|
|
<th className="product-col"><button onClick={() => setSort(nextSort(sort, "product_name"))}>Product</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "category"))}>Category</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</button></th>
|
|
<th className="reason-col"><button onClick={() => setSort(nextSort(sort, "reasoning"))}>Reasoning</button></th>
|
|
<th>Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={15}>Loading...</td></tr>
|
|
) : items?.items.length ? (
|
|
items.items.map((item) => (
|
|
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
|
|
<td><a href={`http://amazon.com/dp/${item.asin}`} target="_blank" rel="noreferrer">{item.asin}</a></td>
|
|
<td><span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span></td>
|
|
<td>{formatNumber(item.monthly_sold)}</td>
|
|
<td>{formatNumber(item.seller_count)}</td>
|
|
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
|
|
<td>{formatNumber(item.amazon_buybox_share_pct_90d)}</td>
|
|
<td>{formatNumber(item.sales_rank)}</td>
|
|
<td>{formatCurrency(item.current_price)}</td>
|
|
<td className="product-col" title={item.reasoning || undefined}>{item.product_name || "-"}</td>
|
|
<td>{item.brand || "-"}</td>
|
|
<td>{item.category || "-"}</td>
|
|
<td>{formatCurrency(item.avg_price_90d)}</td>
|
|
<td>{formatNumber(item.confidence)}</td>
|
|
<td className="reason-col" title={item.reasoning || undefined}>{item.reasoning || "-"}</td>
|
|
<td>
|
|
<button
|
|
onClick={() => reanalyzeAsin(item)}
|
|
disabled={Boolean(reanalyzing[`${item.processType}:${item.runId}:${item.asin}`])}
|
|
>
|
|
{reanalyzing[`${item.processType}:${item.runId}:${item.asin}`] ? "Re-analyzing..." : "Re-analyze"}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr><td colSpan={15}>No products found</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="pager">
|
|
<div>Showing {items?.items.length ?? 0} of {items?.total ?? 0}</div>
|
|
<div>
|
|
<button disabled={page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>Previous</button>
|
|
<span style={{ padding: "0 8px" }}>Page {items?.page ?? page} / {items?.totalPages ?? 1}</span>
|
|
<button disabled={Boolean(items && page >= items.totalPages)} onClick={() => setPage((p) => p + 1)}>Next</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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("");
|
|
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: "persisted_inventory_asin_count", direction: "DESC" });
|
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
|
|
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, 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">
|
|
<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">
|
|
<div className="metric">
|
|
<div className="label">Runs</div>
|
|
<div className="value">{formatNumber(results?.summary.runs)}</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">Sellable 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 seller or sellable ASIN" />
|
|
<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, "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, "discovered_from_count"))}>Hits</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"))}>Sellable sample</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "last_seen_at"))}>Last Seen</button></th>
|
|
<th className="inventory-col">Sellable inventory ASIN sample</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={10}>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.seller_id}`}>
|
|
<td>{item.runId}</td>
|
|
<td>{item.seller_id}</td>
|
|
<td>{item.seller_name || "-"}</td>
|
|
<td>{formatNumber(item.rating)}</td>
|
|
<td>{formatNumber(item.rating_count)}</td>
|
|
<td>{formatNumber(item.discovered_from_count)}</td>
|
|
<td>{formatNumber(item.storefront_asin_total)}</td>
|
|
<td>{formatNumber(item.persisted_inventory_asin_count)}</td>
|
|
<td>{formatDate(item.last_seen_at)}</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={10}>No seller storefronts 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>
|
|
);
|
|
}
|
|
|
|
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 [verdict, setVerdict] = useState("");
|
|
const [amazonIsSeller, setAmazonIsSeller] = useState("");
|
|
const [minPrice, setMinPrice] = useState("");
|
|
const [maxPrice, setMaxPrice] = useState("");
|
|
const [minMonthlySold, setMinMonthlySold] = useState("");
|
|
const [maxMonthlySold, setMaxMonthlySold] = useState("");
|
|
const [minSalesRank, setMinSalesRank] = useState("");
|
|
const [maxSalesRank, setMaxSalesRank] = useState("");
|
|
const [minSellerCount, setMinSellerCount] = useState("");
|
|
const [maxSellerCount, setMaxSellerCount] = useState("");
|
|
const [minRatingCount, setMinRatingCount] = useState("");
|
|
const [maxRatingCount, setMaxRatingCount] = useState("");
|
|
const [minConfidence, setMinConfidence] = useState("");
|
|
const [maxConfidence, setMaxConfidence] = useState("");
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(50);
|
|
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
|
|
|
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
|
const params = new URLSearchParams({
|
|
sort: buildSortValue(sort),
|
|
});
|
|
if (includePaging) {
|
|
params.set("page", String(page));
|
|
params.set("pageSize", String(pageSize));
|
|
}
|
|
if (search) params.set("q", search);
|
|
if (sellerId) params.set("sellerId", sellerId);
|
|
if (runId) params.set("runId", runId);
|
|
if (verdict) params.set("verdict", verdict);
|
|
if (amazonIsSeller) params.set("amazonIsSeller", amazonIsSeller);
|
|
if (minPrice) params.set("minPrice", minPrice);
|
|
if (maxPrice) params.set("maxPrice", maxPrice);
|
|
if (minMonthlySold) params.set("minMonthlySold", minMonthlySold);
|
|
if (maxMonthlySold) params.set("maxMonthlySold", maxMonthlySold);
|
|
if (minSalesRank) params.set("minSalesRank", minSalesRank);
|
|
if (maxSalesRank) params.set("maxSalesRank", maxSalesRank);
|
|
if (minSellerCount) params.set("minSellerCount", minSellerCount);
|
|
if (maxSellerCount) params.set("maxSellerCount", maxSellerCount);
|
|
if (minRatingCount) params.set("minRatingCount", minRatingCount);
|
|
if (maxRatingCount) params.set("maxRatingCount", maxRatingCount);
|
|
if (minConfidence) params.set("minConfidence", minConfidence);
|
|
if (maxConfidence) params.set("maxConfidence", maxConfidence);
|
|
return params;
|
|
}
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function load() {
|
|
setLoading(true);
|
|
const params = buildStalkerProductParams(true);
|
|
|
|
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,
|
|
verdict,
|
|
amazonIsSeller,
|
|
minPrice,
|
|
maxPrice,
|
|
minMonthlySold,
|
|
maxMonthlySold,
|
|
minSalesRank,
|
|
maxSalesRank,
|
|
minSellerCount,
|
|
maxSellerCount,
|
|
minRatingCount,
|
|
maxRatingCount,
|
|
minConfidence,
|
|
maxConfidence,
|
|
page,
|
|
pageSize,
|
|
sort,
|
|
]);
|
|
|
|
function resetFilters() {
|
|
setSearch("");
|
|
setSellerId("");
|
|
setRunId("");
|
|
setVerdict("");
|
|
setAmazonIsSeller("");
|
|
setMinPrice("");
|
|
setMaxPrice("");
|
|
setMinMonthlySold("");
|
|
setMaxMonthlySold("");
|
|
setMinSalesRank("");
|
|
setMaxSalesRank("");
|
|
setMinSellerCount("");
|
|
setMaxSellerCount("");
|
|
setMinRatingCount("");
|
|
setMaxRatingCount("");
|
|
setMinConfidence("");
|
|
setMaxConfidence("");
|
|
setPage(1);
|
|
}
|
|
|
|
const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`;
|
|
|
|
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, product, brand, category, 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={verdict} onChange={(e) => { setPage(1); setVerdict(e.target.value); }}>
|
|
<option value="">All verdicts</option>
|
|
<option value="FBA">FBA</option>
|
|
<option value="FBM">FBM</option>
|
|
<option value="SKIP">SKIP</option>
|
|
<option value="UNANALYZED">Unanalyzed</option>
|
|
</select>
|
|
<select value={amazonIsSeller} onChange={(e) => { setPage(1); setAmazonIsSeller(e.target.value); }}>
|
|
<option value="">Amazon seller: all</option>
|
|
<option value="yes">Amazon seller: yes</option>
|
|
<option value="no">Amazon seller: no</option>
|
|
<option value="unknown">Amazon seller: unknown</option>
|
|
</select>
|
|
<input value={minPrice} onChange={(e) => { setPage(1); setMinPrice(e.target.value); }} placeholder="Min price" />
|
|
<input value={maxPrice} onChange={(e) => { setPage(1); setMaxPrice(e.target.value); }} placeholder="Max price" />
|
|
<input value={minMonthlySold} onChange={(e) => { setPage(1); setMinMonthlySold(e.target.value); }} placeholder="Min monthly sold" />
|
|
<input value={maxMonthlySold} onChange={(e) => { setPage(1); setMaxMonthlySold(e.target.value); }} placeholder="Max monthly sold" />
|
|
<input value={minSalesRank} onChange={(e) => { setPage(1); setMinSalesRank(e.target.value); }} placeholder="Min rank" />
|
|
<input value={maxSalesRank} onChange={(e) => { setPage(1); setMaxSalesRank(e.target.value); }} placeholder="Max rank" />
|
|
<input value={minSellerCount} onChange={(e) => { setPage(1); setMinSellerCount(e.target.value); }} placeholder="Min sellers" />
|
|
<input value={maxSellerCount} onChange={(e) => { setPage(1); setMaxSellerCount(e.target.value); }} placeholder="Max sellers" />
|
|
<input value={minRatingCount} onChange={(e) => { setPage(1); setMinRatingCount(e.target.value); }} placeholder="Min seller rating count" />
|
|
<input value={maxRatingCount} onChange={(e) => { setPage(1); setMaxRatingCount(e.target.value); }} placeholder="Max seller rating count" />
|
|
<input value={minConfidence} onChange={(e) => { setPage(1); setMinConfidence(e.target.value); }} placeholder="Min confidence" />
|
|
<input value={maxConfidence} onChange={(e) => { setPage(1); setMaxConfidence(e.target.value); }} placeholder="Max confidence" />
|
|
<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>
|
|
<button onClick={resetFilters}>Reset filters</button>
|
|
<a className="button-link" href={exportHref}>Export XLSX</a>
|
|
</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 className="product-col"><button onClick={() => setSort(nextSort(sort, "product_title"))}>Product</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "brand"))}>Brand</button></th>
|
|
<th>Category</th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "monthly_sold"))}>Monthly Sold</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "seller_count"))}>Sellers</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "amazon_is_seller"))}>Amazon Seller</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "sales_rank"))}>Sales Rank</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "current_price"))}>Current Price</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "avg_price_90d"))}>Avg 90d</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "verdict"))}>Verdict</button></th>
|
|
<th><button onClick={() => setSort(nextSort(sort, "confidence"))}>Confidence</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={18}>Loading...</td></tr>
|
|
) : results?.items.length ? (
|
|
results.items.map((item) => {
|
|
const categories = parseStringArrayJson(item.category_tree);
|
|
return (
|
|
<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 className="product-col" title={item.product_title || undefined}>{item.product_title || "-"}</td>
|
|
<td>{item.brand || "-"}</td>
|
|
<td>{categories.at(-1) || "-"}</td>
|
|
<td>{formatNumber(item.monthly_sold)}</td>
|
|
<td>{formatNumber(item.seller_count)}</td>
|
|
<td>{formatAmazonSeller(item.amazon_is_seller)}</td>
|
|
<td>{formatNumber(item.sales_rank)}</td>
|
|
<td>{formatCurrency(item.current_price)}</td>
|
|
<td>{formatCurrency(item.avg_price_90d)}</td>
|
|
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
|
<td title={item.reasoning || undefined}>{formatNumber(item.confidence)}</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={18}>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-products" };
|
|
|
|
function parseRoute(pathname: string, search: string): AppRoute {
|
|
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
|
|
if (runMatch) {
|
|
return { kind: "run", processType: runMatch[1] as ProcessType, runId: Number(runMatch[2]) };
|
|
}
|
|
|
|
if (pathname === "/products") {
|
|
const params = new URLSearchParams(search);
|
|
const verdictParam = params.get("verdict");
|
|
const verdict = verdictParam === "FBA" || verdictParam === "FBM" || verdictParam === "SKIP" ? verdictParam : "";
|
|
return { kind: "products", verdict };
|
|
}
|
|
|
|
if (pathname === "/stalker") {
|
|
return { kind: "stalker" };
|
|
}
|
|
|
|
if (pathname === "/stalker/products") {
|
|
return { kind: "stalker-products" };
|
|
}
|
|
|
|
return { kind: "dashboard" };
|
|
}
|
|
|
|
function App() {
|
|
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
|
|
|
|
useEffect(() => {
|
|
const onPopState = () => setRoute(parseRoute(window.location.pathname, window.location.search));
|
|
window.addEventListener("popstate", onPopState);
|
|
return () => window.removeEventListener("popstate", onPopState);
|
|
}, []);
|
|
|
|
function openRun(run: Run) {
|
|
const path = `/runs/${run.processType}/${run.runId}`;
|
|
history.pushState({}, "", path);
|
|
setRoute({ kind: "run", processType: run.processType, runId: run.runId });
|
|
}
|
|
|
|
function openProducts(verdict: VerdictFilter) {
|
|
const path = verdict ? `/products?verdict=${encodeURIComponent(verdict)}` : "/products";
|
|
history.pushState({}, "", path);
|
|
setRoute({ kind: "products", verdict });
|
|
}
|
|
|
|
function openStalker() {
|
|
history.pushState({}, "", "/stalker");
|
|
setRoute({ kind: "stalker" });
|
|
}
|
|
|
|
function openStalkerProducts() {
|
|
history.pushState({}, "", "/stalker/products");
|
|
setRoute({ kind: "stalker-products" });
|
|
}
|
|
|
|
function backToDashboard() {
|
|
history.pushState({}, "", "/");
|
|
setRoute({ kind: "dashboard" });
|
|
}
|
|
|
|
if (route.kind === "run") {
|
|
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
|
|
}
|
|
|
|
if (route.kind === "products") {
|
|
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
|
}
|
|
|
|
if (route.kind === "stalker") {
|
|
return <StalkerExplorer onBack={backToDashboard} onOpenProducts={openStalkerProducts} />;
|
|
}
|
|
|
|
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");
|
|
if (!root) {
|
|
throw new Error("Root element not found");
|
|
}
|
|
|
|
createRoot(root).render(<App />);
|