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:
@@ -7,6 +7,7 @@
|
|||||||
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
"bestsellers": "bun run src/bestsellers-by-category.ts",
|
||||||
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
|
"monthly-sold": "bun run src/top-monthly-sold-by-category.ts",
|
||||||
"mid-range": "bun run src/mid-range-sellers-by-category.ts",
|
"mid-range": "bun run src/mid-range-sellers-by-category.ts",
|
||||||
|
"stalker": "bun run src/stalker.ts",
|
||||||
"upc": "bun run src/upc-lookup.ts",
|
"upc": "bun run src/upc-lookup.ts",
|
||||||
"upc-file": "bun run src/upc-file-analysis.ts",
|
"upc-file": "bun run src/upc-file-analysis.ts",
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
|
|||||||
122
src/database.ts
122
src/database.ts
@@ -333,4 +333,126 @@ export function initDb(dbPath: string): void {
|
|||||||
database.run(
|
database.run(
|
||||||
`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`,
|
`CREATE INDEX IF NOT EXISTS idx_product_results_fetched_at ON product_analysis_results(fetched_at DESC);`,
|
||||||
);
|
);
|
||||||
|
initStalkerDb(database);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initStalkerDb(database: Database): void {
|
||||||
|
resetLegacyStalkerSchema(database);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalker_runs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
input_file TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
requested_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
skipped_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scanned_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source_asins_with_matches INTEGER NOT NULL DEFAULT 0,
|
||||||
|
candidate_sellers INTEGER NOT NULL DEFAULT 0,
|
||||||
|
qualifying_sellers INTEGER NOT NULL DEFAULT 0,
|
||||||
|
matched_sellers INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seller_metadata_requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
seller_storefront_requests INTEGER NOT NULL DEFAULT 0,
|
||||||
|
persisted_inventory_asins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalker_asin_scans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
source_asin TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
offer_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
candidate_seller_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
matched_seller_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fetched_at TEXT NOT NULL,
|
||||||
|
raw_product_json TEXT,
|
||||||
|
UNIQUE(run_id, source_asin),
|
||||||
|
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalker_sellers (
|
||||||
|
seller_id TEXT PRIMARY KEY,
|
||||||
|
seller_name TEXT,
|
||||||
|
rating REAL,
|
||||||
|
rating_count INTEGER,
|
||||||
|
storefront_asin_total INTEGER,
|
||||||
|
persisted_inventory_sample_count INTEGER,
|
||||||
|
last_updated_at TEXT NOT NULL,
|
||||||
|
raw_seller_json TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalker_asin_sellers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
scan_id INTEGER NOT NULL,
|
||||||
|
seller_id TEXT NOT NULL,
|
||||||
|
offer_price REAL,
|
||||||
|
condition TEXT,
|
||||||
|
is_fba INTEGER,
|
||||||
|
stock INTEGER,
|
||||||
|
seller_rating REAL,
|
||||||
|
seller_rating_count INTEGER,
|
||||||
|
raw_offer_json TEXT,
|
||||||
|
UNIQUE(scan_id, seller_id),
|
||||||
|
FOREIGN KEY (scan_id) REFERENCES stalker_asin_scans(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stalker_seller_inventory (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
run_id INTEGER NOT NULL,
|
||||||
|
seller_id TEXT NOT NULL,
|
||||||
|
asin TEXT NOT NULL,
|
||||||
|
last_seen_at TEXT NOT NULL,
|
||||||
|
raw_inventory_json TEXT,
|
||||||
|
UNIQUE(run_id, seller_id, asin),
|
||||||
|
FOREIGN KEY (run_id) REFERENCES stalker_runs(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (seller_id) REFERENCES stalker_sellers(seller_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
database.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stalker_runs_started_at ON stalker_runs(started_at DESC);`,
|
||||||
|
);
|
||||||
|
database.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_run_id ON stalker_asin_scans(run_id);`,
|
||||||
|
);
|
||||||
|
database.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stalker_scans_source_asin ON stalker_asin_scans(source_asin);`,
|
||||||
|
);
|
||||||
|
database.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stalker_asin_sellers_seller_id ON stalker_asin_sellers(seller_id);`,
|
||||||
|
);
|
||||||
|
database.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_seller_id ON stalker_seller_inventory(seller_id);`,
|
||||||
|
);
|
||||||
|
database.run(
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_stalker_inventory_asin ON stalker_seller_inventory(asin);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetLegacyStalkerSchema(database: Database): void {
|
||||||
|
const runColumns = database
|
||||||
|
.query("PRAGMA table_info(stalker_runs)")
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
if (runColumns.length === 0) return;
|
||||||
|
|
||||||
|
const columnNames = new Set(runColumns.map((column) => column.name));
|
||||||
|
if (columnNames.has("scanned_asins")) return;
|
||||||
|
|
||||||
|
database.run("DROP TABLE IF EXISTS stalker_seller_inventory");
|
||||||
|
database.run("DROP TABLE IF EXISTS stalker_asin_sellers");
|
||||||
|
database.run("DROP TABLE IF EXISTS stalker_sellers");
|
||||||
|
database.run("DROP TABLE IF EXISTS stalker_asin_scans");
|
||||||
|
database.run("DROP TABLE IF EXISTS stalker_runs");
|
||||||
}
|
}
|
||||||
|
|||||||
194
src/server.ts
194
src/server.ts
@@ -53,6 +53,31 @@ type ProductListRecord = {
|
|||||||
fetched_at: string;
|
fetched_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StalkerResultRecord = {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
const DB_PATH = process.env.RESULTS_DB_PATH || path.join("db", "results.db");
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const MAX_PAGE_SIZE = 200;
|
const MAX_PAGE_SIZE = 200;
|
||||||
@@ -629,6 +654,170 @@ function getProductList(filters: URLSearchParams) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseStalkerFilters(filters: URLSearchParams) {
|
||||||
|
const q = filters.get("q")?.trim() || "";
|
||||||
|
const sellerId = filters.get("sellerId")?.trim().toUpperCase() || "";
|
||||||
|
const runIdRaw = filters.get("runId")?.trim() || "";
|
||||||
|
const minRatingCountRaw = filters.get("minRatingCount")?.trim() || "";
|
||||||
|
const maxRatingCountRaw = filters.get("maxRatingCount")?.trim() || "";
|
||||||
|
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const params: Array<string | number> = [];
|
||||||
|
|
||||||
|
if (runIdRaw) {
|
||||||
|
const runId = Number(runIdRaw);
|
||||||
|
if (Number.isInteger(runId) && runId > 0) {
|
||||||
|
conditions.push("r.id = ?");
|
||||||
|
params.push(runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sellerId) {
|
||||||
|
conditions.push("s.seller_id = ?");
|
||||||
|
params.push(sellerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minRatingCountRaw) {
|
||||||
|
conditions.push("s.rating_count >= ?");
|
||||||
|
params.push(Number(minRatingCountRaw));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxRatingCountRaw) {
|
||||||
|
conditions.push("s.rating_count <= ?");
|
||||||
|
params.push(Number(maxRatingCountRaw));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
const wildcard = `%${q}%`;
|
||||||
|
conditions.push(
|
||||||
|
`(sc.source_asin LIKE ? OR sc.title LIKE ? OR s.seller_id LIKE ? OR s.seller_name LIKE ? OR EXISTS (
|
||||||
|
SELECT 1 FROM stalker_seller_inventory inv_q
|
||||||
|
WHERE inv_q.run_id = r.id
|
||||||
|
AND inv_q.seller_id = s.seller_id
|
||||||
|
AND inv_q.asin LIKE ?
|
||||||
|
))`,
|
||||||
|
);
|
||||||
|
params.push(wildcard, wildcard, wildcard, wildcard, wildcard);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
where: conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "",
|
||||||
|
params,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStalkerSort(sortParam: string | null): string {
|
||||||
|
const allowedSort = new Set([
|
||||||
|
"runId",
|
||||||
|
"started_at",
|
||||||
|
"source_asin",
|
||||||
|
"title",
|
||||||
|
"seller_id",
|
||||||
|
"seller_name",
|
||||||
|
"rating",
|
||||||
|
"rating_count",
|
||||||
|
"offer_price",
|
||||||
|
"stock",
|
||||||
|
"persisted_inventory_asin_count",
|
||||||
|
"storefront_asin_total",
|
||||||
|
"scan_fetched_at",
|
||||||
|
]);
|
||||||
|
const parsed = parseSort(
|
||||||
|
sortParam,
|
||||||
|
allowedSort,
|
||||||
|
"started_at DESC, runId DESC, source_asin ASC",
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
.replaceAll("runId", "runId")
|
||||||
|
.replaceAll("rating_count", "rating_count")
|
||||||
|
.replaceAll("persisted_inventory_asin_count", "persisted_inventory_asin_count")
|
||||||
|
.replaceAll("storefront_asin_total", "storefront_asin_total");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStalkerResults(filters: URLSearchParams) {
|
||||||
|
const page = parseIntParam(filters.get("page"), 1);
|
||||||
|
const pageSize = Math.min(
|
||||||
|
parseIntParam(filters.get("pageSize"), DEFAULT_PAGE_SIZE),
|
||||||
|
MAX_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const { where, params } = parseStalkerFilters(filters);
|
||||||
|
const orderBy = parseStalkerSort(filters.get("sort"));
|
||||||
|
|
||||||
|
const baseSelect = `
|
||||||
|
SELECT
|
||||||
|
r.id AS runId,
|
||||||
|
r.started_at,
|
||||||
|
r.status,
|
||||||
|
r.input_file,
|
||||||
|
sc.source_asin,
|
||||||
|
sc.title,
|
||||||
|
sc.offer_count,
|
||||||
|
sc.candidate_seller_count,
|
||||||
|
sc.matched_seller_count,
|
||||||
|
sc.fetched_at AS scan_fetched_at,
|
||||||
|
s.seller_id,
|
||||||
|
s.seller_name,
|
||||||
|
s.rating,
|
||||||
|
s.rating_count,
|
||||||
|
s.storefront_asin_total,
|
||||||
|
s.persisted_inventory_sample_count,
|
||||||
|
sas.offer_price,
|
||||||
|
sas.condition,
|
||||||
|
sas.is_fba,
|
||||||
|
sas.stock,
|
||||||
|
COUNT(DISTINCT inv.asin) AS persisted_inventory_asin_count,
|
||||||
|
GROUP_CONCAT(DISTINCT inv.asin) AS inventory_sample_asins
|
||||||
|
FROM stalker_asin_sellers sas
|
||||||
|
JOIN stalker_asin_scans sc ON sc.id = sas.scan_id
|
||||||
|
JOIN stalker_runs r ON r.id = sc.run_id
|
||||||
|
JOIN stalker_sellers s ON s.seller_id = sas.seller_id
|
||||||
|
LEFT JOIN stalker_seller_inventory inv
|
||||||
|
ON inv.run_id = r.id
|
||||||
|
AND inv.seller_id = s.seller_id
|
||||||
|
${where}
|
||||||
|
GROUP BY sas.id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const totalRow = db
|
||||||
|
.query(`SELECT COUNT(*) AS total FROM (${baseSelect}) stalker_rows`)
|
||||||
|
.get(...params) as { total: number };
|
||||||
|
|
||||||
|
const summary = db
|
||||||
|
.query(
|
||||||
|
`SELECT
|
||||||
|
COUNT(DISTINCT runId) AS runs,
|
||||||
|
COUNT(DISTINCT source_asin) AS sourceAsins,
|
||||||
|
COUNT(DISTINCT seller_id) AS sellers,
|
||||||
|
COALESCE(SUM(persisted_inventory_asin_count), 0) AS persistedInventoryAsins
|
||||||
|
FROM (${baseSelect}) stalker_rows`,
|
||||||
|
)
|
||||||
|
.get(...params) as {
|
||||||
|
runs: number;
|
||||||
|
sourceAsins: number;
|
||||||
|
sellers: number;
|
||||||
|
persistedInventoryAsins: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = db
|
||||||
|
.query(
|
||||||
|
`SELECT * FROM (${baseSelect}) stalker_rows
|
||||||
|
ORDER BY ${orderBy}
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
)
|
||||||
|
.all(...params, pageSize, offset) as StalkerResultRecord[];
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
summary,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total: totalRow.total,
|
||||||
|
totalPages: Math.max(1, Math.ceil(totalRow.total / pageSize)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getRun(processType: ProcessType, runId: number) {
|
function getRun(processType: ProcessType, runId: number) {
|
||||||
if (processType === "lead_analysis") {
|
if (processType === "lead_analysis") {
|
||||||
const run = db
|
const run = db
|
||||||
@@ -1259,6 +1448,7 @@ const server = Bun.serve({
|
|||||||
routes: {
|
routes: {
|
||||||
"/": index,
|
"/": index,
|
||||||
"/products": index,
|
"/products": index,
|
||||||
|
"/stalker": index,
|
||||||
"/runs/:processType/:runId": index,
|
"/runs/:processType/:runId": index,
|
||||||
"/api/runs": (req) => {
|
"/api/runs": (req) => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -1268,6 +1458,10 @@ const server = Bun.serve({
|
|||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
return json(getProductList(url.searchParams));
|
return json(getProductList(url.searchParams));
|
||||||
},
|
},
|
||||||
|
"/api/stalker/results": (req) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
return json(getStalkerResults(url.searchParams));
|
||||||
|
},
|
||||||
"/api/upc/map": async (req) => {
|
"/api/upc/map": async (req) => {
|
||||||
let upcs: string[];
|
let upcs: string[];
|
||||||
try {
|
try {
|
||||||
|
|||||||
281
src/stalker.test.ts
Normal file
281
src/stalker.test.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
||||||
|
import { mkdirSync, rmSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import * as XLSX from "xlsx";
|
||||||
|
import { closeDb, getDb, initDb } from "./database.ts";
|
||||||
|
import {
|
||||||
|
extractLiveOfferSellerCandidates,
|
||||||
|
isQualifyingSeller,
|
||||||
|
readAsinsFromXlsx,
|
||||||
|
runStalker,
|
||||||
|
} from "./stalker.ts";
|
||||||
|
|
||||||
|
const TEST_DIR = path.join(process.cwd(), "test_output", "stalker");
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalKeepaKey = Bun.env.KEEPA_API_KEY;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
closeDb();
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
mkdirSync(TEST_DIR, { recursive: true });
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
Bun.env.KEEPA_API_KEY = "test-keepa-key";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
if (originalKeepaKey == null) {
|
||||||
|
delete Bun.env.KEEPA_API_KEY;
|
||||||
|
} else {
|
||||||
|
Bun.env.KEEPA_API_KEY = originalKeepaKey;
|
||||||
|
}
|
||||||
|
closeDb();
|
||||||
|
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("readAsinsFromXlsx extracts valid ASINs and deduplicates in order", () => {
|
||||||
|
const filePath = path.join(TEST_DIR, "asins.xlsx");
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
const sheet = XLSX.utils.json_to_sheet([
|
||||||
|
{ ASIN: "b000000001" },
|
||||||
|
{ ASIN: "invalid" },
|
||||||
|
{ ASIN: "B000000002" },
|
||||||
|
{ ASIN: "B000000001" },
|
||||||
|
{ ASIN: "" },
|
||||||
|
]);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, sheet, "Input");
|
||||||
|
XLSX.writeFile(workbook, filePath);
|
||||||
|
|
||||||
|
expect(readAsinsFromXlsx(filePath)).toEqual(["B000000001", "B000000002"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isQualifyingSeller accepts rating counts from 1 to 30 only", () => {
|
||||||
|
expect(isQualifyingSeller({ ratingCount: null })).toBe(false);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 0 })).toBe(false);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 1 })).toBe(true);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 30 })).toBe(true);
|
||||||
|
expect(isQualifyingSeller({ ratingCount: 31 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extractLiveOfferSellerCandidates ignores Amazon, missing seller IDs, and duplicates", () => {
|
||||||
|
const offers = extractLiveOfferSellerCandidates({
|
||||||
|
offers: [
|
||||||
|
{ sellerId: "ATVPDKIKX0DER", price: 1999 },
|
||||||
|
{ price: 1899 },
|
||||||
|
{ sellerId: "A1SELLER", price: 1599, isFBA: true, stock: 4 },
|
||||||
|
{ sellerId: "A1SELLER", price: 1499 },
|
||||||
|
{ sellerID: "A2SELLER", currentPrice: 2499, isFba: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(offers.map((offer) => offer.sellerId)).toEqual([
|
||||||
|
"A1SELLER",
|
||||||
|
"A2SELLER",
|
||||||
|
]);
|
||||||
|
expect(offers[0]?.offerPrice).toBe(15.99);
|
||||||
|
expect(offers[0]?.isFba).toBe(true);
|
||||||
|
expect(offers[0]?.stock).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("initDb creates stalker tables and indexes", () => {
|
||||||
|
const dbPath = path.join(TEST_DIR, "schema.sqlite");
|
||||||
|
initDb(dbPath);
|
||||||
|
const db = getDb(dbPath);
|
||||||
|
|
||||||
|
const tables = db
|
||||||
|
.query(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'stalker_%' ORDER BY name`,
|
||||||
|
)
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
expect(tables.map((row) => row.name)).toEqual([
|
||||||
|
"stalker_asin_scans",
|
||||||
|
"stalker_asin_sellers",
|
||||||
|
"stalker_runs",
|
||||||
|
"stalker_seller_inventory",
|
||||||
|
"stalker_sellers",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const indexes = db
|
||||||
|
.query(
|
||||||
|
`SELECT name FROM sqlite_master WHERE type = 'index' AND name LIKE 'idx_stalker_%' ORDER BY name`,
|
||||||
|
)
|
||||||
|
.all() as Array<{ name: string }>;
|
||||||
|
expect(indexes.length).toBeGreaterThanOrEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("runStalker fetches product offers, filters sellers, and persists storefront inventory", async () => {
|
||||||
|
const inputPath = path.join(TEST_DIR, "input.xlsx");
|
||||||
|
const dbPath = path.join(TEST_DIR, "stalker.sqlite");
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(
|
||||||
|
workbook,
|
||||||
|
XLSX.utils.json_to_sheet([{ asin: "B000000001" }]),
|
||||||
|
"Input",
|
||||||
|
);
|
||||||
|
XLSX.writeFile(workbook, inputPath);
|
||||||
|
|
||||||
|
const fetchMock = mock(async (input: string | URL | Request) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.toString()
|
||||||
|
: input.url;
|
||||||
|
const url = new URL(rawUrl);
|
||||||
|
|
||||||
|
if (url.pathname === "/product") {
|
||||||
|
expect(url.searchParams.get("asin")).toBe("B000000001");
|
||||||
|
expect(url.searchParams.get("offers")).toBe("20");
|
||||||
|
expect(url.searchParams.get("only-live-offers")).toBe("1");
|
||||||
|
expect(url.searchParams.has("stock")).toBe(false);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
asin: "B000000001",
|
||||||
|
title: "Tracked Product",
|
||||||
|
offers: [
|
||||||
|
{
|
||||||
|
sellerId: "AQUALIFIED",
|
||||||
|
price: 1999,
|
||||||
|
condition: "New",
|
||||||
|
isFBA: true,
|
||||||
|
stock: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sellerId: "AOLDSELLER",
|
||||||
|
price: 2099,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/seller") {
|
||||||
|
const wantsStorefront = url.searchParams.get("storefront") === "1";
|
||||||
|
if (wantsStorefront) {
|
||||||
|
expect(url.searchParams.get("update")).toBe("168");
|
||||||
|
}
|
||||||
|
const sellerId = url.searchParams.get("seller");
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sellers: {
|
||||||
|
...(!wantsStorefront && sellerId === "AQUALIFIED,AOLDSELLER"
|
||||||
|
? {
|
||||||
|
AQUALIFIED: {
|
||||||
|
sellerName: "New Seller",
|
||||||
|
currentRating: 96,
|
||||||
|
currentRatingCount: 12,
|
||||||
|
},
|
||||||
|
AOLDSELLER: {
|
||||||
|
sellerName: "Old Seller",
|
||||||
|
currentRating: 99,
|
||||||
|
currentRatingCount: 120,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(wantsStorefront && sellerId === "AQUALIFIED"
|
||||||
|
? {
|
||||||
|
AQUALIFIED: {
|
||||||
|
sellerName: "New Seller",
|
||||||
|
currentRating: 96,
|
||||||
|
currentRatingCount: 12,
|
||||||
|
asinList: ["B111111111", "B222222222"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
tokensLeft: 10,
|
||||||
|
refillRate: 10,
|
||||||
|
}),
|
||||||
|
{ status: 200 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("not found", { status: 404 });
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
|
||||||
|
|
||||||
|
const stats = await runStalker({
|
||||||
|
input: inputPath,
|
||||||
|
dbPath,
|
||||||
|
maxAsins: null,
|
||||||
|
storefrontUpdateHours: 168,
|
||||||
|
offerLimit: 20,
|
||||||
|
sellerLimit: 30,
|
||||||
|
inventoryLimit: 200,
|
||||||
|
sellerCacheHours: 168,
|
||||||
|
includeStock: false,
|
||||||
|
dryRun: false,
|
||||||
|
resume: true,
|
||||||
|
maxSellerRequests: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stats.scannedAsins).toBe(1);
|
||||||
|
expect(stats.sourceAsinsWithMatches).toBe(1);
|
||||||
|
expect(stats.matchedSellers).toBe(1);
|
||||||
|
expect(stats.persistedInventoryAsins).toBe(2);
|
||||||
|
expect(stats.failedAsins).toBe(0);
|
||||||
|
expect(stats.candidateSellers).toBe(2);
|
||||||
|
expect(stats.qualifyingSellers).toBe(1);
|
||||||
|
expect(stats.sellerMetadataRequests).toBe(1);
|
||||||
|
expect(stats.sellerStorefrontRequests).toBe(1);
|
||||||
|
const sellerCalls = fetchMock.mock.calls.filter((call) => {
|
||||||
|
const rawUrl =
|
||||||
|
typeof call[0] === "string"
|
||||||
|
? call[0]
|
||||||
|
: call[0] instanceof URL
|
||||||
|
? call[0].toString()
|
||||||
|
: (call[0] as Request).url;
|
||||||
|
return new URL(rawUrl).pathname === "/seller";
|
||||||
|
});
|
||||||
|
expect(sellerCalls.length).toBe(2);
|
||||||
|
|
||||||
|
const db = getDb(dbPath);
|
||||||
|
const run = db.query("SELECT * FROM stalker_runs").get() as any;
|
||||||
|
expect(run.status).toBe("completed");
|
||||||
|
expect(run.requested_asins).toBe(1);
|
||||||
|
expect(run.scanned_asins).toBe(1);
|
||||||
|
expect(run.source_asins_with_matches).toBe(1);
|
||||||
|
expect(run.candidate_sellers).toBe(2);
|
||||||
|
expect(run.qualifying_sellers).toBe(1);
|
||||||
|
expect(run.matched_sellers).toBe(1);
|
||||||
|
expect(run.seller_metadata_requests).toBe(1);
|
||||||
|
expect(run.seller_storefront_requests).toBe(1);
|
||||||
|
expect(run.persisted_inventory_asins).toBe(2);
|
||||||
|
|
||||||
|
const scan = db.query("SELECT * FROM stalker_asin_scans").get() as any;
|
||||||
|
expect(scan.source_asin).toBe("B000000001");
|
||||||
|
expect(scan.title).toBe("Tracked Product");
|
||||||
|
expect(scan.offer_count).toBe(2);
|
||||||
|
expect(scan.candidate_seller_count).toBe(2);
|
||||||
|
expect(scan.matched_seller_count).toBe(1);
|
||||||
|
|
||||||
|
const sellers = db.query("SELECT * FROM stalker_sellers").all() as any[];
|
||||||
|
expect(sellers.length).toBe(1);
|
||||||
|
expect(sellers[0].seller_id).toBe("AQUALIFIED");
|
||||||
|
expect(sellers[0].rating_count).toBe(12);
|
||||||
|
expect(sellers[0].storefront_asin_total).toBe(2);
|
||||||
|
expect(sellers[0].persisted_inventory_sample_count).toBe(2);
|
||||||
|
|
||||||
|
const asinSellers = db.query("SELECT * FROM stalker_asin_sellers").all() as any[];
|
||||||
|
expect(asinSellers.length).toBe(1);
|
||||||
|
expect(asinSellers[0].offer_price).toBe(19.99);
|
||||||
|
expect(asinSellers[0].is_fba).toBe(1);
|
||||||
|
expect(asinSellers[0].stock).toBe(3);
|
||||||
|
|
||||||
|
const inventory = db
|
||||||
|
.query("SELECT asin FROM stalker_seller_inventory ORDER BY asin")
|
||||||
|
.all() as Array<{ asin: string }>;
|
||||||
|
expect(inventory.map((row) => row.asin)).toEqual([
|
||||||
|
"B111111111",
|
||||||
|
"B222222222",
|
||||||
|
]);
|
||||||
|
});
|
||||||
1189
src/stalker.ts
Normal file
1189
src/stalker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -109,6 +109,45 @@ type ProductListResponse = {
|
|||||||
totalPages: number;
|
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 = {
|
type SortState = {
|
||||||
field: string;
|
field: string;
|
||||||
direction: SortDirection;
|
direction: SortDirection;
|
||||||
@@ -139,6 +178,11 @@ function formatAmazonSeller(value: number | null | undefined): string {
|
|||||||
return value === 1 ? "Yes" : "No";
|
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 {
|
function buildSortValue(sort: SortState): string {
|
||||||
return `${sort.field}:${sort.direction}`;
|
return `${sort.field}:${sort.direction}`;
|
||||||
}
|
}
|
||||||
@@ -197,9 +241,11 @@ function detectAnomaly(item: ResultItem): string {
|
|||||||
function Dashboard({
|
function Dashboard({
|
||||||
onOpenRun,
|
onOpenRun,
|
||||||
onOpenProducts,
|
onOpenProducts,
|
||||||
|
onOpenStalker,
|
||||||
}: {
|
}: {
|
||||||
onOpenRun: (run: Run) => void;
|
onOpenRun: (run: Run) => void;
|
||||||
onOpenProducts: (verdict: VerdictFilter) => void;
|
onOpenProducts: (verdict: VerdictFilter) => void;
|
||||||
|
onOpenStalker: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -288,7 +334,10 @@ function Dashboard({
|
|||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
<div className="section-header">
|
||||||
<h2>Runs Dashboard</h2>
|
<h2>Runs Dashboard</h2>
|
||||||
|
<button onClick={onOpenStalker}>Stalker results</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="metrics">
|
<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 =
|
type AppRoute =
|
||||||
| { kind: "dashboard" }
|
| { kind: "dashboard" }
|
||||||
| { kind: "run"; processType: ProcessType; runId: number }
|
| { kind: "run"; processType: ProcessType; runId: number }
|
||||||
| { kind: "products"; verdict: VerdictFilter };
|
| { kind: "products"; verdict: VerdictFilter }
|
||||||
|
| { kind: "stalker" };
|
||||||
|
|
||||||
function parseRoute(pathname: string, search: string): AppRoute {
|
function parseRoute(pathname: string, search: string): AppRoute {
|
||||||
const runMatch = pathname.match(/^\/runs\/(lead_analysis|category_analysis)\/(\d+)$/);
|
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 };
|
return { kind: "products", verdict };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathname === "/stalker") {
|
||||||
|
return { kind: "stalker" };
|
||||||
|
}
|
||||||
|
|
||||||
return { kind: "dashboard" };
|
return { kind: "dashboard" };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,6 +1099,11 @@ function App() {
|
|||||||
setRoute({ kind: "products", verdict });
|
setRoute({ kind: "products", verdict });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openStalker() {
|
||||||
|
history.pushState({}, "", "/stalker");
|
||||||
|
setRoute({ kind: "stalker" });
|
||||||
|
}
|
||||||
|
|
||||||
function backToDashboard() {
|
function backToDashboard() {
|
||||||
history.pushState({}, "", "/");
|
history.pushState({}, "", "/");
|
||||||
setRoute({ kind: "dashboard" });
|
setRoute({ kind: "dashboard" });
|
||||||
@@ -903,7 +1117,11 @@ function App() {
|
|||||||
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
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");
|
const root = document.getElementById("root");
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ p {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar input,
|
.toolbar input,
|
||||||
.toolbar select,
|
.toolbar select,
|
||||||
button {
|
button {
|
||||||
@@ -91,6 +98,23 @@ td {
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inventory-col {
|
||||||
|
min-width: 360px;
|
||||||
|
max-width: 520px;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-col a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stalker-table {
|
||||||
|
min-width: 1320px;
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
background: #fafafb;
|
background: #fafafb;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -262,4 +286,9 @@ th button {
|
|||||||
.spark-grid {
|
.spark-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user