Compare commits

..

3 Commits

Author SHA1 Message Date
Victor Noguera
a355359427 feat: implement filter presets and view state persistence across dashboard, run details, product list, and stalker explorer
- Added functionality to save, update, and apply filter presets for various views.
- Introduced local storage management for persisting view states across sessions.
- Enhanced dashboard, run details, product list, and stalker explorer components to utilize saved filter presets.
- Updated UI to include controls for managing filter presets.
2026-05-25 16:59:06 -04:00
Victor Noguera
31cf992e77 refactor: rename findLatestStalkerRunItemIdByAsin to findLatestRunItemIdByAsin and update references 2026-05-25 16:02:07 -04:00
Victor Noguera
506e2344b7 feat: implement reanalyze and distributor discovery endpoints for Stalker products by ASIN 2026-05-25 15:57:24 -04:00
3 changed files with 943 additions and 162 deletions

View File

@@ -25,6 +25,9 @@
"Bash(git --no-pager diff -- src/web/frontend.tsx src/web/styles.css 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)",
"Bash(bun run build:web 2>&1 || true)"
]
},

View File

@@ -3,10 +3,7 @@ import * as XLSX from "xlsx";
import { normalizeAsin } from "./asin.ts";
import { db, client } from "./db/index.ts";
import { eq } from "drizzle-orm";
import {
analysisRevisions,
productDistributorResearch,
} from "./db/schema.ts";
import { analysisRevisions, productDistributorResearch } from "./db/schema.ts";
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
import { config } from "./config.ts";
import {
@@ -46,7 +43,10 @@ async function pgGet<T extends Record<string, unknown>>(
query: string,
params: unknown[] = [],
): Promise<T | null> {
const rows = await client.unsafe<T[]>(toPostgresSql(query), params as never[]);
const rows = await client.unsafe<T[]>(
toPostgresSql(query),
params as never[],
);
return (rows[0] as T) ?? null;
}
@@ -54,7 +54,10 @@ async function pgAll<T extends Record<string, unknown>>(
query: string,
params: unknown[] = [],
): Promise<T[]> {
return client.unsafe<T[]>(toPostgresSql(query), params as never[]) as unknown as T[];
return client.unsafe<T[]>(
toPostgresSql(query),
params as never[],
) as unknown as T[];
}
async function pgRun(query: string, params: unknown[] = []): Promise<number> {
@@ -127,7 +130,10 @@ function safeSort(
}
function splitRawUpcValues(input: string): string[] {
return input.split(/[\s,;|]+/).map((v) => v.trim()).filter(Boolean);
return input
.split(/[\s,;|]+/)
.map((v) => v.trim())
.filter(Boolean);
}
function collectUpcs(value: unknown, target: string[]): void {
@@ -288,7 +294,13 @@ async function getRuns(filters: URLSearchParams) {
[...params, pageSize, offset],
);
const total = Number(totalRow?.total ?? 0);
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
return {
items,
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
async function getRun(runId: number) {
@@ -412,7 +424,11 @@ const ITEM_SORTS: Record<string, string> = {
async function getRunItems(runId: number, filters: URLSearchParams) {
const { page, pageSize, offset } = pageInput(filters);
const { where, params } = itemFilters(filters, runId);
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "monthly_sold DESC NULLS LAST, asin ASC");
const orderBy = safeSort(
filters.get("sort"),
ITEM_SORTS,
"monthly_sold DESC NULLS LAST, asin ASC",
);
const totalRow = await pgGet<{ total: string }>(
`SELECT COUNT(*) AS total FROM (${ITEM_ROWS}) item_rows ${where}`,
params,
@@ -422,29 +438,60 @@ async function getRunItems(runId: number, filters: URLSearchParams) {
[...params, pageSize, offset],
);
const total = Number(totalRow?.total ?? 0);
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
return {
items,
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
async function exportRunItems(runId: number, filters: URLSearchParams) {
const { where, params } = itemFilters(filters, runId);
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "monthly_sold DESC NULLS LAST, asin ASC");
const orderBy = safeSort(
filters.get("sort"),
ITEM_SORTS,
"monthly_sold DESC NULLS LAST, asin ASC",
);
const rows = await pgAll<Record<string, unknown>>(
`SELECT * FROM (${ITEM_ROWS}) item_rows ${where} ORDER BY ${orderBy}`,
params,
);
const headers = [
"run_id", "asin", "product_name", "brand", "category", "unit_cost",
"current_price", "avg_price_90d", "sales_rank_avg_90d", "seller_count",
"amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold",
"sellability_status", "verdict", "confidence", "reasoning", "fetched_at",
"run_id",
"asin",
"product_name",
"brand",
"category",
"unit_cost",
"current_price",
"avg_price_90d",
"sales_rank_avg_90d",
"seller_count",
"amazon_is_seller",
"amazon_buybox_share_pct_90d",
"monthly_sold",
"sellability_status",
"verdict",
"confidence",
"reasoning",
"fetched_at",
];
return [headers.join(","), ...rows.map((row) => headers.map((h) => escapeCsvValue(row[h])).join(","))].join("\n");
return [
headers.join(","),
...rows.map((row) => headers.map((h) => escapeCsvValue(row[h])).join(",")),
].join("\n");
}
async function getProducts(filters: URLSearchParams) {
const { page, pageSize, offset } = pageInput(filters);
const { where, params } = itemFilters(filters);
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "fetched_at DESC NULLS LAST, asin ASC");
const orderBy = safeSort(
filters.get("sort"),
ITEM_SORTS,
"fetched_at DESC NULLS LAST, asin ASC",
);
const base = `
SELECT product.asin, product.asin AS product_asin,
latest.item_id, latest.run_id AS "runId", latest.process_type AS "processType",
@@ -466,23 +513,28 @@ async function getProducts(filters: URLSearchParams) {
LIMIT 1
) latest ON TRUE`;
const total = Number(
(await pgGet<{ total: string }>(
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
params,
))?.total ?? 0,
(
await pgGet<{ total: string }>(
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
params,
)
)?.total ?? 0,
);
const items = await pgAll(
`SELECT * FROM (${base}) products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
[...params, pageSize, offset],
);
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
return {
items,
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
async function getProduct(asin: string) {
const product = await pgGet(
`SELECT * FROM products WHERE asin = ?`,
[asin],
);
const product = await pgGet(`SELECT * FROM products WHERE asin = ?`, [asin]);
if (!product) return null;
const observations = await pgAll(
`SELECT observation.*, run.type AS run_type
@@ -511,7 +563,9 @@ async function getProduct(asin: string) {
const distributorResearch = distributorResearchRows.map((row) => {
const distributors = (() => {
try {
return normalizeDistributorCandidates(JSON.parse(String(row.distributors_json ?? "[]")));
return normalizeDistributorCandidates(
JSON.parse(String(row.distributors_json ?? "[]")),
);
} catch {
return [];
}
@@ -519,7 +573,8 @@ async function getProduct(asin: string) {
return {
id: Number(row.id),
run_item_id: row.run_item_id == null ? null : Number(row.run_item_id),
inventory_item_id: row.inventory_item_id == null ? null : Number(row.inventory_item_id),
inventory_item_id:
row.inventory_item_id == null ? null : Number(row.inventory_item_id),
provider: String(row.provider ?? ""),
model: String(row.model ?? ""),
status: String(row.status ?? ""),
@@ -531,6 +586,37 @@ async function getProduct(asin: string) {
return { product, observations, analyses, distributorResearch };
}
async function findLatestRunItemIdByAsin(asin: string): Promise<number | null> {
const row = await pgGet<{ id: number }>(
`SELECT ri.id
FROM run_items ri
WHERE ri.product_asin = ?
ORDER BY ri.id DESC
LIMIT 1`,
[asin],
);
return row?.id == null ? null : Number(row.id);
}
async function reanalyzeStalkerProductByAsin(
asin: string,
useClaude = USE_CLAUDE,
) {
const runItemId = await findLatestRunItemIdByAsin(asin);
if (runItemId == null) {
throw new Error("Stalker product item not found");
}
return reanalyzeRunItem(runItemId, useClaude);
}
async function findDistributorsForStalkerProductByAsin(asin: string) {
const runItemId = await findLatestRunItemIdByAsin(asin);
if (runItemId == null) {
throw new Error("Stalker product item not found");
}
return findDistributorsForStalkerProduct(runItemId);
}
async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
const row = await pgGet<Record<string, any>>(
`SELECT ri.id, ri.run_id, ri.product_asin AS asin, r.type,
@@ -551,7 +637,9 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
);
if (!row) throw new Error("Run item not found");
if (row.type === "supplier_upc") {
throw new Error("Supplier scoring revisions are produced by the supplier pipeline");
throw new Error(
"Supplier scoring revisions are produced by the supplier pipeline",
);
}
const record: ProductRecord = {
asin: row.asin,
@@ -592,15 +680,18 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
spApi: spApi as SpApiData,
fetchedAt: new Date().toISOString(),
};
const verdict =
(await analyzeProducts([enriched], { useClaude }))[0] ?? {
asin: row.asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM analysis returned no verdict",
};
const verdict = (await analyzeProducts([enriched], { useClaude }))[0] ?? {
asin: row.asin,
verdict: "SKIP" as const,
confidence: 0,
reasoning: "LLM analysis returned no verdict",
};
const result: AnalysisResult = { product: enriched, verdict };
const observationId = await insertObservation(row.run_id, result, "reanalysis");
const observationId = await insertObservation(
row.run_id,
result,
"reanalysis",
);
await db.insert(analysisRevisions).values({
runItemId: itemId,
observationId,
@@ -611,7 +702,12 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
analyzedAt: new Date(enriched.fetchedAt),
});
await refreshRunStats(row.run_id);
return { itemId, runId: row.run_id, asin: row.asin, fetchedAt: enriched.fetchedAt };
return {
itemId,
runId: row.run_id,
asin: row.asin,
fetchedAt: enriched.fetchedAt,
};
}
type DistributorCandidate = {
@@ -630,10 +726,15 @@ function clampDistributorConfidence(value: unknown): number {
return Math.max(0, Math.min(100, Math.round(parsed)));
}
function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[] {
function normalizeDistributorCandidates(
payload: unknown,
): DistributorCandidate[] {
if (!Array.isArray(payload)) return [];
return payload
.filter((item): item is Record<string, unknown> => item != null && typeof item === "object")
.filter(
(item): item is Record<string, unknown> =>
item != null && typeof item === "object",
)
.map((item) => ({
name: String(item.name ?? "").trim(),
website: String(item.website ?? "").trim(),
@@ -641,7 +742,9 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
confidence: clampDistributorConfidence(item.confidence),
reputation: String(item.reputation ?? "").trim(),
contactInfo: String(item.contact_info ?? item.contactInfo ?? "").trim(),
outreachDraft: String(item.outreach_draft ?? item.outreachDraft ?? "").trim(),
outreachDraft: String(
item.outreach_draft ?? item.outreachDraft ?? "",
).trim(),
}))
.filter((item) => item.name.length > 0 && item.website.length > 0)
.slice(0, 10);
@@ -650,7 +753,7 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
function extractJsonArrayFromText(text: string): string {
const trimmed = text.trim();
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = fence ? fence[1]?.trim() ?? "" : trimmed;
const candidate = fence ? (fence[1]?.trim() ?? "") : trimmed;
const start = candidate.indexOf("[");
const end = candidate.lastIndexOf("]");
if (start >= 0 && end > start) {
@@ -659,11 +762,14 @@ function extractJsonArrayFromText(text: string): string {
return candidate;
}
async function requestClaudeDistributorCandidates(context: Record<string, unknown>) {
async function requestClaudeDistributorCandidates(
context: Record<string, unknown>,
) {
if (!config.anthropicApiKey) {
throw new Error("Missing required env var: ANTHROPIC_API_KEY");
}
const model = (config.anthropicModel ?? "claude-sonnet-4-6").trim() || "claude-sonnet-4-6";
// const model = (config.anthropicModel ?? "claude-sonnet-4-6").trim() || "claude-sonnet-4-6";
const model = "claude-sonnet-4-6";
const system = [
"You are a wholesale sourcing researcher who identifies authorized U.S. distributors for Amazon products.",
"For each candidate you research their reputation, locate real point-of-contact details, and draft a concise cold-outreach message.",
@@ -707,13 +813,19 @@ async function requestClaudeDistributorCandidates(context: Record<string, unknow
});
const raw = await response.text();
if (!response.ok) {
throw new Error(`Claude API error ${response.status}: ${raw.slice(0, 300)}`);
throw new Error(
`Claude API error ${response.status}: ${raw.slice(0, 300)}`,
);
}
let contentText = "";
try {
const parsed = JSON.parse(raw) as { content?: Array<{ type?: string; text?: string }> };
const parsed = JSON.parse(raw) as {
content?: Array<{ type?: string; text?: string }>;
};
contentText = (parsed.content ?? [])
.filter((block) => block?.type === "text" && typeof block.text === "string")
.filter(
(block) => block?.type === "text" && typeof block.text === "string",
)
.map((block) => block.text ?? "")
.join("\n");
} catch {
@@ -817,7 +929,10 @@ async function findDistributorsForStalkerProduct(runItemId: number) {
distributorsJson: JSON.stringify(claude.candidates),
rawResponse: claude.rawResponse,
})
.returning({ id: productDistributorResearch.id, createdAt: productDistributorResearch.createdAt });
.returning({
id: productDistributorResearch.id,
createdAt: productDistributorResearch.createdAt,
});
return {
asin: row.asin,
runItemId: runItemId,
@@ -866,7 +981,10 @@ function stalkerBaseWhere(filters: URLSearchParams, product = false) {
}
}
if (product) {
conditions.push("observation.can_sell = true", "observation.sellability_status = 'available'");
conditions.push(
"observation.can_sell = true",
"observation.sellability_status = 'available'",
);
const verdict = filters.get("verdict")?.toUpperCase();
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
conditions.push("analysis.decision::text = ?");
@@ -936,7 +1054,14 @@ async function getStalkerResults(filters: URLSearchParams) {
},
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC",
);
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) rows`, params))?.total ?? 0);
const total = Number(
(
await pgGet<{ total: string }>(
`SELECT COUNT(*) AS total FROM (${base}) rows`,
params,
)
)?.total ?? 0,
);
const items = await pgAll(
`SELECT * FROM (${base}) rows ORDER BY ${order} LIMIT ? OFFSET ?`,
[...params, pageSize, offset],
@@ -954,7 +1079,10 @@ async function getStalkerResults(filters: URLSearchParams) {
sellers: Number(summary?.sellers ?? 0),
persistedInventoryAsins: Number(summary?.persistedInventoryAsins ?? 0),
},
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
@@ -1012,8 +1140,16 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
},
"monthly_sold DESC NULLS LAST, last_seen_at DESC, asin ASC",
);
if (exportOnly) return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) products`, params))?.total ?? 0);
if (exportOnly)
return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
const total = Number(
(
await pgGet<{ total: string }>(
`SELECT COUNT(*) AS total FROM (${base}) products`,
params,
)
)?.total ?? 0,
);
const items = await pgAll(
`SELECT * FROM (${base}) products ORDER BY ${order} LIMIT ? OFFSET ?`,
[...params, pageSize, offset],
@@ -1030,12 +1166,19 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
sellers: Number(summary?.sellers ?? 0),
products: Number(summary?.products ?? 0),
},
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
}
async function exportStalkerProducts(filters: URLSearchParams): Promise<Response> {
const rows = (await stalkerProducts(filters, true)) as Array<Record<string, any>>;
async function exportStalkerProducts(
filters: URLSearchParams,
): Promise<Response> {
const rows = (await stalkerProducts(filters, true)) as Array<
Record<string, any>
>;
const data = rows.map((row) => ({
ASIN: row.asin,
"Amazon URL": `https://amazon.com/dp/${row.asin}`,
@@ -1053,7 +1196,11 @@ async function exportStalkerProducts(filters: URLSearchParams): Promise<Response
"Run ID": row.runId,
}));
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(data), "Sellable Products");
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.json_to_sheet(data),
"Sellable Products",
);
return xlsx(
XLSX.write(workbook, { type: "array", bookType: "xlsx" }) as ArrayBuffer,
"stalker-sellable-products.xlsx",
@@ -1082,13 +1229,17 @@ const server = Bun.serve({
"/stalker": index,
"/stalker/products": index,
"/runs/:runId": index,
"/api/runs": async (req) => json(await getRuns(new URL(req.url).searchParams)),
"/api/runs": async (req) =>
json(await getRuns(new URL(req.url).searchParams)),
"/api/runs/:runId": async (req) => {
const runId = Number(req.params.runId);
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
if (!Number.isInteger(runId))
return json({ error: "Invalid run identifier" }, 400);
if (req.method === "DELETE") {
const deleted = await pgRun("DELETE FROM runs WHERE id = ?", [runId]);
return deleted ? json({ deletedRun: true }) : json({ error: "Run not found" }, 404);
return deleted
? json({ deletedRun: true })
: json({ error: "Run not found" }, 404);
}
const run = await getRun(runId);
if (!run) return json({ error: "Run not found" }, 404);
@@ -1106,57 +1257,121 @@ const server = Bun.serve({
},
"/api/runs/:runId/items": async (req) => {
const runId = Number(req.params.runId);
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
if (!Number.isInteger(runId))
return json({ error: "Invalid run identifier" }, 400);
return json(await getRunItems(runId, new URL(req.url).searchParams));
},
"/api/runs/:runId/export.csv": async (req) => {
const runId = Number(req.params.runId);
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
return csv(await exportRunItems(runId, new URL(req.url).searchParams), `run-${runId}.csv`);
if (!Number.isInteger(runId))
return json({ error: "Invalid run identifier" }, 400);
return csv(
await exportRunItems(runId, new URL(req.url).searchParams),
`run-${runId}.csv`,
);
},
"/api/run-items/:itemId/reanalyze": async (req) => {
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
if (req.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const itemId = Number(req.params.itemId);
if (!Number.isInteger(itemId)) return json({ error: "Invalid run item identifier" }, 400);
if (!Number.isInteger(itemId))
return json({ error: "Invalid run item identifier" }, 400);
try {
return json(await reanalyzeRunItem(itemId));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return json({ error: message }, message === "Run item not found" ? 404 : 500);
return json(
{ error: message },
message === "Run item not found" ? 404 : 500,
);
}
},
"/api/products": async (req) => json(await getProducts(new URL(req.url).searchParams)),
"/api/products": async (req) =>
json(await getProducts(new URL(req.url).searchParams)),
"/api/products/:asin": async (req) => {
const asin = normalizeAsin(req.params.asin);
if (!asin) return json({ error: "Invalid ASIN" }, 400);
const result = await getProduct(asin);
return result ? json(result) : json({ error: "Product not found" }, 404);
},
"/api/stalker/results": async (req) => json(await getStalkerResults(new URL(req.url).searchParams)),
"/api/stalker/products": async (req) => json(await stalkerProducts(new URL(req.url).searchParams)),
"/api/stalker/products/export.xlsx": async (req) => exportStalkerProducts(new URL(req.url).searchParams),
"/api/stalker/results": async (req) =>
json(await getStalkerResults(new URL(req.url).searchParams)),
"/api/stalker/products": async (req) =>
json(await stalkerProducts(new URL(req.url).searchParams)),
"/api/stalker/products/export.xlsx": async (req) =>
exportStalkerProducts(new URL(req.url).searchParams),
"/api/stalker/products/:runItemId/reanalyze": async (req) => {
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
if (req.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const runItemId = Number(req.params.runItemId);
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
const provider = new URL(req.url).searchParams.get("provider")?.trim().toLowerCase();
if (!Number.isInteger(runItemId))
return json({ error: "Invalid run item identifier" }, 400);
const provider = new URL(req.url).searchParams
.get("provider")
?.trim()
.toLowerCase();
const useClaude = provider === "claude";
try {
return json(await reanalyzeRunItem(runItemId, useClaude || USE_CLAUDE));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return json({ error: message }, message === "Run item not found" ? 404 : 500);
return json(
{ error: message },
message === "Run item not found" ? 404 : 500,
);
}
},
"/api/stalker/products/:runItemId/distributors": async (req) => {
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
if (req.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const runItemId = Number(req.params.runItemId);
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
if (!Number.isInteger(runItemId))
return json({ error: "Invalid run item identifier" }, 400);
try {
return json(await findDistributorsForStalkerProduct(runItemId));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return json({ error: message }, message === "Stalker product item not found" ? 404 : 500);
return json(
{ error: message },
message === "Stalker product item not found" ? 404 : 500,
);
}
},
"/api/stalker/products/by-asin/:asin/reanalyze": async (req) => {
if (req.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const asin = normalizeAsin(req.params.asin);
if (!asin) return json({ error: "Invalid ASIN" }, 400);
const provider = new URL(req.url).searchParams
.get("provider")
?.trim()
.toLowerCase();
const useClaude = provider === "claude";
try {
return json(
await reanalyzeStalkerProductByAsin(asin, useClaude || USE_CLAUDE),
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return json(
{ error: message },
message === "Stalker product item not found" ? 404 : 500,
);
}
},
"/api/stalker/products/by-asin/:asin/distributors": async (req) => {
if (req.method !== "POST")
return json({ error: "Method not allowed" }, 405);
const asin = normalizeAsin(req.params.asin);
if (!asin) return json({ error: "Invalid ASIN" }, 400);
try {
return json(await findDistributorsForStalkerProductByAsin(asin));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return json(
{ error: message },
message === "Stalker product item not found" ? 404 : 500,
);
}
},
"/api/stalker/purge": async (req) =>
@@ -1168,10 +1383,15 @@ const server = Bun.serve({
const upcs = await parseUpcsFromRequest(req);
const error = validateUpcs(upcs);
if (error) return json({ error }, 400);
const items = [...(await mapUpcsToAsins(upcs)).entries()].map(([upc, asin]) => ({ upc, asin }));
const items = [...(await mapUpcsToAsins(upcs)).entries()].map(
([upc, asin]) => ({ upc, asin }),
);
return json({ requested: upcs.length, matched: items.length, items });
} catch (error) {
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
return json(
{ error: error instanceof Error ? error.message : String(error) },
400,
);
}
},
"/api/upc/lookup": async (req) => {
@@ -1180,16 +1400,31 @@ const server = Bun.serve({
const error = validateUpcs(upcs);
if (error) return json({ error }, 400);
const items = [...(await lookupKeepaUpcs(upcs)).values()];
return json({ requested: upcs.length, statusCounts: summarizeLookupStatuses(items), items });
return json({
requested: upcs.length,
statusCounts: summarizeLookupStatuses(items),
items,
});
} catch (error) {
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
return json(
{ error: error instanceof Error ? error.message : String(error) },
400,
);
}
},
"/api/process/upc-file": async (req) => {
try {
return json(await runUpcFileAnalysis({ ...(await parseUpcFileRequest(req)), manageResources: false }));
return json(
await runUpcFileAnalysis({
...(await parseUpcFileRequest(req)),
manageResources: false,
}),
);
} catch (error) {
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
return json(
{ error: error instanceof Error ? error.message : String(error) },
400,
);
}
},
},

View File

@@ -300,6 +300,85 @@ function nextSort(current: SortState, field: string): SortState {
};
}
const VIEW_STATE_KEY = "asin-check.view-state.v1";
const FILTER_PRESETS_KEY = "asin-check.filter-presets.v1";
type SavedFilterPreset = {
id: string;
name: string;
viewKey: string;
state: Record<string, unknown>;
updatedAt: string;
};
function readJsonStorage<T>(key: string, fallback: T): T {
try {
const raw = window.localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeJsonStorage<T>(key: string, value: T): void {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
}
}
function readViewState(viewKey: string): Record<string, unknown> {
const all = readJsonStorage<Record<string, Record<string, unknown>>>(VIEW_STATE_KEY, {});
const value = all[viewKey];
return value && typeof value === "object" ? value : {};
}
function saveViewState(viewKey: string, state: Record<string, unknown>): void {
const all = readJsonStorage<Record<string, Record<string, unknown>>>(VIEW_STATE_KEY, {});
all[viewKey] = state;
writeJsonStorage(VIEW_STATE_KEY, all);
}
function listFilterPresets(viewKey: string): SavedFilterPreset[] {
const all = readJsonStorage<SavedFilterPreset[]>(FILTER_PRESETS_KEY, []);
return all.filter((preset) => preset.viewKey === viewKey);
}
function saveFilterPreset(viewKey: string, name: string, state: Record<string, unknown>): SavedFilterPreset {
const all = readJsonStorage<SavedFilterPreset[]>(FILTER_PRESETS_KEY, []);
const normalizedName = name.trim().toLowerCase();
const existing = all.find((preset) => preset.viewKey === viewKey && preset.name.trim().toLowerCase() === normalizedName);
if (existing) {
existing.state = state;
existing.updatedAt = new Date().toISOString();
writeJsonStorage(FILTER_PRESETS_KEY, all);
return existing;
}
const created: SavedFilterPreset = {
id: typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`,
name: name.trim(),
viewKey,
state,
updatedAt: new Date().toISOString(),
};
all.unshift(created);
writeJsonStorage(FILTER_PRESETS_KEY, all);
return created;
}
function updateFilterPreset(presetId: string, state: Record<string, unknown>): SavedFilterPreset | null {
const all = readJsonStorage<SavedFilterPreset[]>(FILTER_PRESETS_KEY, []);
const existing = all.find((preset) => preset.id === presetId);
if (!existing) return null;
existing.state = state;
existing.updatedAt = new Date().toISOString();
writeJsonStorage(FILTER_PRESETS_KEY, all);
return existing;
}
function statusBadgeClass(status: string): string {
if (status === "ok" || status === "completed") return "badge badge-ok";
if (status === "failed") return "badge badge-failed";
@@ -367,18 +446,90 @@ function Dashboard({
onOpenStalker: () => void;
onOpenStalkerProducts: () => void;
}) {
const viewKey = "dashboard";
const initialState = useMemo(() => readViewState(viewKey), []);
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 [search, setSearch] = useState(String(initialState.search ?? ""));
const [processType, setProcessType] = useState(String(initialState.processType ?? ""));
const [status, setStatus] = useState(String(initialState.status ?? ""));
const [startDate, setStartDate] = useState(String(initialState.startDate ?? ""));
const [endDate, setEndDate] = useState(String(initialState.endDate ?? ""));
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
const [sort, setSort] = useState<SortState>({
field: String(initialState.sortField ?? "timestamp"),
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
});
const [refreshTick, setRefreshTick] = useState(0);
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const [presetName, setPresetName] = useState("");
const [selectedPresetId, setSelectedPresetId] = useState("");
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
function snapshotFilters(): Record<string, unknown> {
return {
search,
processType,
status,
startDate,
endDate,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
};
}
function applyFilterState(state: Record<string, unknown>) {
setSearch(String(state.search ?? ""));
setProcessType(String(state.processType ?? ""));
setStatus(String(state.status ?? ""));
setStartDate(String(state.startDate ?? ""));
setEndDate(String(state.endDate ?? ""));
setPage(Number(state.page ?? 1) || 1);
setPageSize(Number(state.pageSize ?? 25) || 25);
setSort({
field: String(state.sortField ?? "timestamp"),
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
});
}
function saveCurrentPreset() {
if (!presetName.trim()) return;
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
setPresets(listFilterPresets(viewKey));
setSelectedPresetId(saved.id);
setPresetName(saved.name);
}
function applySelectedPreset() {
if (!selectedPresetId) return;
const preset = presets.find((item) => item.id === selectedPresetId);
if (!preset) return;
applyFilterState(preset.state);
}
function updateSelectedPreset() {
if (!selectedPresetId) return;
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
if (!updated) return;
setPresets(listFilterPresets(viewKey));
}
useEffect(() => {
saveViewState(viewKey, {
search,
processType,
status,
startDate,
endDate,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
});
}, [viewKey, search, processType, status, startDate, endDate, page, pageSize, sort]);
const summary = useMemo(() => {
if (!runs) return { total: 0, fba: 0, fbm: 0, skip: 0 };
@@ -504,6 +655,14 @@ function Dashboard({
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
<option value="">Select preset</option>
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<button onClick={saveCurrentPreset}>Save</button>
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
</div>
</div>
@@ -596,21 +755,96 @@ function RunDetails({
runId: number;
onBack: () => void;
}) {
const viewKey = `run-details:${runId}`;
const initialState = useMemo(() => readViewState(viewKey), [viewKey]);
const [run, setRun] = useState<RunDetail | null>(null);
const [results, setResults] = useState<ResultsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [verdict, setVerdict] = useState("");
const [sellabilityStatus, setSellabilityStatus] = useState("");
const [search, setSearch] = useState(String(initialState.search ?? ""));
const [verdict, setVerdict] = useState(String(initialState.verdict ?? ""));
const [sellabilityStatus, setSellabilityStatus] = useState(String(initialState.sellabilityStatus ?? ""));
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" });
useState<AmazonSellerFilter>(initialState.amazonSellerFilter === "yes" || initialState.amazonSellerFilter === "no" ? initialState.amazonSellerFilter : "");
const [minConfidence, setMinConfidence] = useState(String(initialState.minConfidence ?? ""));
const [maxConfidence, setMaxConfidence] = useState(String(initialState.maxConfidence ?? ""));
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
const [sort, setSort] = useState<SortState>({
field: String(initialState.sortField ?? "monthly_sold"),
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
});
const [refreshTick, setRefreshTick] = useState(0);
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
const [presetName, setPresetName] = useState("");
const [selectedPresetId, setSelectedPresetId] = useState("");
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
useEffect(() => {
saveViewState(viewKey, {
search,
verdict,
sellabilityStatus,
amazonSellerFilter,
minConfidence,
maxConfidence,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
});
}, [viewKey, search, verdict, sellabilityStatus, amazonSellerFilter, minConfidence, maxConfidence, page, pageSize, sort]);
function snapshotFilters(): Record<string, unknown> {
return {
search,
verdict,
sellabilityStatus,
amazonSellerFilter,
minConfidence,
maxConfidence,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
};
}
function applyFilterState(state: Record<string, unknown>) {
setSearch(String(state.search ?? ""));
setVerdict(String(state.verdict ?? ""));
setSellabilityStatus(String(state.sellabilityStatus ?? ""));
setAmazonSellerFilter(state.amazonSellerFilter === "yes" || state.amazonSellerFilter === "no" ? state.amazonSellerFilter : "");
setMinConfidence(String(state.minConfidence ?? ""));
setMaxConfidence(String(state.maxConfidence ?? ""));
setPage(Number(state.page ?? 1) || 1);
setPageSize(Number(state.pageSize ?? 25) || 25);
setSort({
field: String(state.sortField ?? "monthly_sold"),
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
});
}
function saveCurrentPreset() {
if (!presetName.trim()) return;
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
setPresets(listFilterPresets(viewKey));
setSelectedPresetId(saved.id);
setPresetName(saved.name);
}
function applySelectedPreset() {
if (!selectedPresetId) return;
const preset = presets.find((item) => item.id === selectedPresetId);
if (!preset) return;
applyFilterState(preset.state);
}
function updateSelectedPreset() {
if (!selectedPresetId) return;
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
if (!updated) return;
setPresets(listFilterPresets(viewKey));
}
const anomalies = useMemo(() => {
if (!results) return [] as ResultItem[];
@@ -755,6 +989,14 @@ function RunDetails({
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
<option value="">Select preset</option>
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<button onClick={saveCurrentPreset}>Save</button>
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
</div>
<div style={{ marginTop: 10 }}>
<a
@@ -773,7 +1015,7 @@ function RunDetails({
<div className="anomaly-list" style={{ marginTop: 8 }}>
{anomalies.slice(0, 8).map((item) => (
<div key={`anom-${item.asin}-${item.fetched_at}`} className="anomaly-item">
{item.product_asin ? <a href={`/products/${item.product_asin}`}>{item.asin}</a> : item.asin}
{item.product_asin ? <button onClick={() => onOpenProduct(item.product_asin!, item.item_id)}>{item.asin}</button> : item.asin}
{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}
<span>{detectAnomaly(item)}</span>
</div>
@@ -810,7 +1052,7 @@ function RunDetails({
) : results?.items.length ? (
results.items.map((item) => (
<tr key={`${item.asin}-${item.fetched_at}`}>
<td>{item.product_asin ? <a href={`/products/${item.product_asin}`}>{item.asin}</a> : item.asin}</td>
<td>{item.product_asin ? <button onClick={() => onOpenProduct(item.product_asin!, item.item_id)}>{item.asin}</button> : item.asin}</td>
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
@@ -855,17 +1097,83 @@ function RunDetails({
);
}
function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () => void }) {
function ProductList({ verdict, onBack, onOpenProduct }: { verdict: VerdictFilter; onBack: () => void; onOpenProduct: (asin: string, runItemId: number | null) => void }) {
const viewKey = "products";
const initialState = useMemo(() => readViewState(viewKey), []);
const [items, setItems] = useState<ProductListResponse | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
const [search, setSearch] = useState(String(initialState.search ?? ""));
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(String(initialState.activeVerdict ?? verdict) as VerdictFilter);
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" });
useState<AmazonSellerFilter>(initialState.amazonSellerFilter === "yes" || initialState.amazonSellerFilter === "no" ? initialState.amazonSellerFilter : "");
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
const [sort, setSort] = useState<SortState>({
field: String(initialState.sortField ?? "monthly_sold"),
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
});
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
const [presetName, setPresetName] = useState("");
const [selectedPresetId, setSelectedPresetId] = useState("");
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
useEffect(() => {
saveViewState(viewKey, {
search,
activeVerdict,
amazonSellerFilter,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
});
}, [viewKey, search, activeVerdict, amazonSellerFilter, page, pageSize, sort]);
function snapshotFilters(): Record<string, unknown> {
return {
search,
activeVerdict,
amazonSellerFilter,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
};
}
function applyFilterState(state: Record<string, unknown>) {
setSearch(String(state.search ?? ""));
setActiveVerdict(String(state.activeVerdict ?? "") as VerdictFilter);
setAmazonSellerFilter(state.amazonSellerFilter === "yes" || state.amazonSellerFilter === "no" ? state.amazonSellerFilter : "");
setPage(Number(state.page ?? 1) || 1);
setPageSize(Number(state.pageSize ?? 25) || 25);
setSort({
field: String(state.sortField ?? "monthly_sold"),
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
});
}
function saveCurrentPreset() {
if (!presetName.trim()) return;
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
setPresets(listFilterPresets(viewKey));
setSelectedPresetId(saved.id);
setPresetName(saved.name);
}
function applySelectedPreset() {
if (!selectedPresetId) return;
const preset = presets.find((item) => item.id === selectedPresetId);
if (!preset) return;
applyFilterState(preset.state);
}
function updateSelectedPreset() {
if (!selectedPresetId) return;
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
if (!updated) return;
setPresets(listFilterPresets(viewKey));
}
useEffect(() => {
setActiveVerdict(verdict);
@@ -958,6 +1266,14 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
<option value="">Select preset</option>
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<button onClick={saveCurrentPreset}>Save</button>
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
</div>
</div>
<div className="card">
@@ -988,7 +1304,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
) : items?.items.length ? (
items.items.map((item) => (
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
<td><a href={`/products/${item.asin}`}>{item.asin}</a></td>
<td><button onClick={() => onOpenProduct(item.asin, item.item_id)}>{item.asin}</button></td>
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
<td>{formatNumber(item.monthly_sold)}</td>
<td>{formatNumber(item.seller_count)}</td>
@@ -1040,18 +1356,90 @@ function StalkerExplorer({
onBack: () => void;
onOpenProducts: () => void;
}) {
const viewKey = "stalker";
const initialState = useMemo(() => readViewState(viewKey), []);
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 [search, setSearch] = useState(String(initialState.search ?? ""));
const [sellerId, setSellerId] = useState(String(initialState.sellerId ?? ""));
const [runId, setRunId] = useState(String(initialState.runId ?? ""));
const [minRatingCount, setMinRatingCount] = useState(String(initialState.minRatingCount ?? "1"));
const [maxRatingCount, setMaxRatingCount] = useState(String(initialState.maxRatingCount ?? "30"));
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
const [sort, setSort] = useState<SortState>({
field: String(initialState.sortField ?? "persisted_inventory_asin_count"),
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
});
const [refreshTick, setRefreshTick] = useState(0);
const [presetName, setPresetName] = useState("");
const [selectedPresetId, setSelectedPresetId] = useState("");
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
useEffect(() => {
saveViewState(viewKey, {
search,
sellerId,
runId,
minRatingCount,
maxRatingCount,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
});
}, [viewKey, search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort]);
function snapshotFilters(): Record<string, unknown> {
return {
search,
sellerId,
runId,
minRatingCount,
maxRatingCount,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
};
}
function applyFilterState(state: Record<string, unknown>) {
setSearch(String(state.search ?? ""));
setSellerId(String(state.sellerId ?? ""));
setRunId(String(state.runId ?? ""));
setMinRatingCount(String(state.minRatingCount ?? "1"));
setMaxRatingCount(String(state.maxRatingCount ?? "30"));
setPage(Number(state.page ?? 1) || 1);
setPageSize(Number(state.pageSize ?? 25) || 25);
setSort({
field: String(state.sortField ?? "persisted_inventory_asin_count"),
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
});
}
function saveCurrentPreset() {
if (!presetName.trim()) return;
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
setPresets(listFilterPresets(viewKey));
setSelectedPresetId(saved.id);
setPresetName(saved.name);
}
function applySelectedPreset() {
if (!selectedPresetId) return;
const preset = presets.find((item) => item.id === selectedPresetId);
if (!preset) return;
applyFilterState(preset.state);
}
function updateSelectedPreset() {
if (!selectedPresetId) return;
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
if (!updated) return;
setPresets(listFilterPresets(viewKey));
}
useEffect(() => {
let cancelled = false;
@@ -1144,6 +1532,14 @@ function StalkerExplorer({
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
<option value="">Select preset</option>
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<button onClick={saveCurrentPreset}>Save</button>
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
</div>
</div>
@@ -1220,31 +1616,148 @@ function StalkerProductsExplorer({
onOpenSellers: () => void;
onOpenProduct: (asin: string, runItemId: number | null) => void;
}) {
const viewKey = "stalker-products";
const initialState = useMemo(() => readViewState(viewKey), []);
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" });
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
const [showSellerColumn, setShowSellerColumn] = useState(false);
const [showCategoryColumn, setShowCategoryColumn] = useState(false);
const [search, setSearch] = useState(String(initialState.search ?? ""));
const [sellerId, setSellerId] = useState(String(initialState.sellerId ?? ""));
const [runId, setRunId] = useState(String(initialState.runId ?? ""));
const [verdict, setVerdict] = useState(String(initialState.verdict ?? ""));
const [amazonIsSeller, setAmazonIsSeller] = useState(String(initialState.amazonIsSeller ?? ""));
const [minPrice, setMinPrice] = useState(String(initialState.minPrice ?? ""));
const [maxPrice, setMaxPrice] = useState(String(initialState.maxPrice ?? ""));
const [minMonthlySold, setMinMonthlySold] = useState(String(initialState.minMonthlySold ?? ""));
const [maxMonthlySold, setMaxMonthlySold] = useState(String(initialState.maxMonthlySold ?? ""));
const [minSalesRank, setMinSalesRank] = useState(String(initialState.minSalesRank ?? ""));
const [maxSalesRank, setMaxSalesRank] = useState(String(initialState.maxSalesRank ?? ""));
const [minSellerCount, setMinSellerCount] = useState(String(initialState.minSellerCount ?? ""));
const [maxSellerCount, setMaxSellerCount] = useState(String(initialState.maxSellerCount ?? ""));
const [minRatingCount, setMinRatingCount] = useState(String(initialState.minRatingCount ?? ""));
const [maxRatingCount, setMaxRatingCount] = useState(String(initialState.maxRatingCount ?? ""));
const [minConfidence, setMinConfidence] = useState(String(initialState.minConfidence ?? ""));
const [maxConfidence, setMaxConfidence] = useState(String(initialState.maxConfidence ?? ""));
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 50) || 50);
const [sort, setSort] = useState<SortState>({
field: String(initialState.sortField ?? "monthly_sold"),
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
});
const [showSellerIdColumn, setShowSellerIdColumn] = useState(Boolean(initialState.showSellerIdColumn ?? false));
const [showSellerColumn, setShowSellerColumn] = useState(Boolean(initialState.showSellerColumn ?? false));
const [showCategoryColumn, setShowCategoryColumn] = useState(Boolean(initialState.showCategoryColumn ?? false));
const [presetName, setPresetName] = useState("");
const [selectedPresetId, setSelectedPresetId] = useState("");
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
useEffect(() => {
saveViewState(viewKey, {
search,
sellerId,
runId,
verdict,
amazonIsSeller,
minPrice,
maxPrice,
minMonthlySold,
maxMonthlySold,
minSalesRank,
maxSalesRank,
minSellerCount,
maxSellerCount,
minRatingCount,
maxRatingCount,
minConfidence,
maxConfidence,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
showSellerIdColumn,
showSellerColumn,
showCategoryColumn,
});
}, [viewKey, search, sellerId, runId, verdict, amazonIsSeller, minPrice, maxPrice, minMonthlySold, maxMonthlySold, minSalesRank, maxSalesRank, minSellerCount, maxSellerCount, minRatingCount, maxRatingCount, minConfidence, maxConfidence, page, pageSize, sort, showSellerIdColumn, showSellerColumn, showCategoryColumn]);
function snapshotFilters(): Record<string, unknown> {
return {
search,
sellerId,
runId,
verdict,
amazonIsSeller,
minPrice,
maxPrice,
minMonthlySold,
maxMonthlySold,
minSalesRank,
maxSalesRank,
minSellerCount,
maxSellerCount,
minRatingCount,
maxRatingCount,
minConfidence,
maxConfidence,
page,
pageSize,
sortField: sort.field,
sortDirection: sort.direction,
showSellerIdColumn,
showSellerColumn,
showCategoryColumn,
};
}
function applyFilterState(state: Record<string, unknown>) {
setSearch(String(state.search ?? ""));
setSellerId(String(state.sellerId ?? ""));
setRunId(String(state.runId ?? ""));
setVerdict(String(state.verdict ?? ""));
setAmazonIsSeller(String(state.amazonIsSeller ?? ""));
setMinPrice(String(state.minPrice ?? ""));
setMaxPrice(String(state.maxPrice ?? ""));
setMinMonthlySold(String(state.minMonthlySold ?? ""));
setMaxMonthlySold(String(state.maxMonthlySold ?? ""));
setMinSalesRank(String(state.minSalesRank ?? ""));
setMaxSalesRank(String(state.maxSalesRank ?? ""));
setMinSellerCount(String(state.minSellerCount ?? ""));
setMaxSellerCount(String(state.maxSellerCount ?? ""));
setMinRatingCount(String(state.minRatingCount ?? ""));
setMaxRatingCount(String(state.maxRatingCount ?? ""));
setMinConfidence(String(state.minConfidence ?? ""));
setMaxConfidence(String(state.maxConfidence ?? ""));
setPage(Number(state.page ?? 1) || 1);
setPageSize(Number(state.pageSize ?? 50) || 50);
setSort({
field: String(state.sortField ?? "monthly_sold"),
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
});
setShowSellerIdColumn(Boolean(state.showSellerIdColumn ?? false));
setShowSellerColumn(Boolean(state.showSellerColumn ?? false));
setShowCategoryColumn(Boolean(state.showCategoryColumn ?? false));
}
function saveCurrentPreset() {
if (!presetName.trim()) return;
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
setPresets(listFilterPresets(viewKey));
setSelectedPresetId(saved.id);
setPresetName(saved.name);
}
function applySelectedPreset() {
if (!selectedPresetId) return;
const preset = presets.find((item) => item.id === selectedPresetId);
if (!preset) return;
applyFilterState(preset.state);
}
function updateSelectedPreset() {
if (!selectedPresetId) return;
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
if (!updated) return;
setPresets(listFilterPresets(viewKey));
}
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
const params = new URLSearchParams({
@@ -1399,6 +1912,14 @@ function StalkerProductsExplorer({
<option value="50">50 / page</option>
<option value="100">100 / page</option>
</select>
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
<option value="">Select preset</option>
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
</select>
<button onClick={saveCurrentPreset}>Save</button>
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
<button onClick={resetFilters}>Reset filters</button>
<a className="button-link" href={exportHref}>Export XLSX</a>
</div>
@@ -1514,10 +2035,13 @@ function ProductDetails({
}, [asin]);
async function reanalyze() {
if (effectiveRunItemId == null || reanalyzing) return;
if (reanalyzing) return;
setReanalyzing(true);
try {
const res = await fetch(`/api/stalker/products/${effectiveRunItemId}/reanalyze?provider=claude`, { method: "POST" });
const endpoint = effectiveRunItemId == null
? `/api/stalker/products/by-asin/${encodeURIComponent(asin)}/reanalyze?provider=claude`
: `/api/stalker/products/${effectiveRunItemId}/reanalyze?provider=claude`;
const res = await fetch(endpoint, { method: "POST" });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
window.alert(body?.error ?? "Failed to re-run analysis");
@@ -1530,16 +2054,18 @@ function ProductDetails({
}
async function discoverDistributors() {
if (effectiveRunItemId == null || findingDistributors) return;
if (findingDistributors) return;
setFindingDistributors(true);
try {
const res = await fetch(`/api/stalker/products/${effectiveRunItemId}/distributors`, { method: "POST" });
const endpoint = effectiveRunItemId == null
? `/api/stalker/products/by-asin/${encodeURIComponent(asin)}/distributors`
: `/api/stalker/products/${effectiveRunItemId}/distributors`;
const res = await fetch(endpoint, { method: "POST" });
if (!res.ok) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
window.alert(body?.error ?? "Failed to find distributors");
}
} catch {
// network error or timeout — job may have completed on the server anyway
} finally {
load();
setFindingDistributors(false);
@@ -1552,16 +2078,14 @@ function ProductDetails({
<div className="card">
<div className="section-header">
<h2>{data?.product.name ?? asin}</h2>
{effectiveRunItemId != null && (
<div className="button-row">
<button onClick={reanalyze} disabled={reanalyzing}>
{reanalyzing ? "Re-running..." : "Re-run analysis"}
</button>
<button onClick={discoverDistributors} disabled={findingDistributors}>
{findingDistributors ? "Finding distributors..." : "Find distributors"}
</button>
</div>
)}
<div className="button-row">
<button onClick={reanalyze} disabled={reanalyzing}>
{reanalyzing ? "Re-running..." : "Re-run analysis"}
</button>
<button onClick={discoverDistributors} disabled={findingDistributors}>
{findingDistributors ? "Finding distributors..." : "Find distributors"}
</button>
</div>
</div>
<div className="meta-grid" style={{ marginTop: 12 }}>
<div className="meta"><strong>ASIN:</strong> <a href={`https://amazon.com/dp/${asin}`} target="_blank" rel="noreferrer">{asin}</a></div>
@@ -1673,14 +2197,15 @@ function ProductDetails({
);
}
type AppRoute =
type NonProductRoute =
| { kind: "dashboard" }
| { kind: "run"; processType: ProcessType; runId: number }
| { kind: "products"; verdict: VerdictFilter }
| { kind: "product"; asin: string; runItemId?: number | null }
| { kind: "stalker" }
| { kind: "stalker-products" };
type AppRoute = NonProductRoute | { kind: "product"; asin: string; runItemId?: number | null; backTo?: NonProductRoute };
function parseRoute(pathname: string, search: string): AppRoute {
const runMatch = pathname.match(/^\/runs\/(\d+)$/);
if (runMatch) {
@@ -1710,6 +2235,14 @@ function parseRoute(pathname: string, search: string): AppRoute {
return { kind: "dashboard" };
}
function routePath(route: NonProductRoute): string {
if (route.kind === "run") return `/runs/${route.runId}`;
if (route.kind === "products") return route.verdict ? `/products?verdict=${encodeURIComponent(route.verdict)}` : "/products";
if (route.kind === "stalker") return "/stalker";
if (route.kind === "stalker-products") return "/stalker/products";
return "/";
}
function App() {
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
@@ -1746,21 +2279,31 @@ function App() {
setRoute({ kind: "dashboard" });
}
function backFromProduct() {
if (route.kind === "product" && route.backTo) {
history.pushState({}, "", routePath(route.backTo));
setRoute(route.backTo);
return;
}
backToDashboard();
}
function openProduct(asin: string, runItemId: number | null) {
history.pushState({}, "", `/products/${asin}`);
setRoute({ kind: "product", asin, runItemId });
const backTo: NonProductRoute | undefined = route.kind === "product" ? undefined : route;
setRoute({ kind: "product", asin, runItemId, backTo });
}
if (route.kind === "run") {
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} onOpenProduct={openProduct} />;
}
if (route.kind === "products") {
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
return <ProductList verdict={route.verdict} onBack={backToDashboard} onOpenProduct={openProduct} />;
}
if (route.kind === "product") {
return <ProductDetails asin={route.asin} runItemId={route.runItemId} onBack={backToDashboard} />;
return <ProductDetails asin={route.asin} runItemId={route.runItemId} onBack={backFromProduct} />;
}
if (route.kind === "stalker") {