Compare commits
3 Commits
313677692b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a355359427 | ||
|
|
31cf992e77 | ||
|
|
506e2344b7 |
@@ -25,6 +25,9 @@
|
|||||||
"Bash(git --no-pager diff -- src/web/frontend.tsx src/web/styles.css 2>&1 || true)",
|
"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)",
|
||||||
|
"Bash(bun run build:web 2>&1 || true)",
|
||||||
"Bash(bun run build:web 2>&1 || true)"
|
"Bash(bun run build:web 2>&1 || true)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
387
src/server.ts
387
src/server.ts
@@ -3,10 +3,7 @@ import * as XLSX from "xlsx";
|
|||||||
import { normalizeAsin } from "./asin.ts";
|
import { normalizeAsin } from "./asin.ts";
|
||||||
import { db, client } from "./db/index.ts";
|
import { db, client } from "./db/index.ts";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import { analysisRevisions, productDistributorResearch } from "./db/schema.ts";
|
||||||
analysisRevisions,
|
|
||||||
productDistributorResearch,
|
|
||||||
} from "./db/schema.ts";
|
|
||||||
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
||||||
import { config } from "./config.ts";
|
import { config } from "./config.ts";
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +43,10 @@ async function pgGet<T extends Record<string, unknown>>(
|
|||||||
query: string,
|
query: string,
|
||||||
params: unknown[] = [],
|
params: unknown[] = [],
|
||||||
): Promise<T | null> {
|
): 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;
|
return (rows[0] as T) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,10 @@ async function pgAll<T extends Record<string, unknown>>(
|
|||||||
query: string,
|
query: string,
|
||||||
params: unknown[] = [],
|
params: unknown[] = [],
|
||||||
): Promise<T[]> {
|
): 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> {
|
async function pgRun(query: string, params: unknown[] = []): Promise<number> {
|
||||||
@@ -127,7 +130,10 @@ function safeSort(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function splitRawUpcValues(input: string): string[] {
|
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 {
|
function collectUpcs(value: unknown, target: string[]): void {
|
||||||
@@ -288,7 +294,13 @@ async function getRuns(filters: URLSearchParams) {
|
|||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
);
|
);
|
||||||
const total = Number(totalRow?.total ?? 0);
|
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) {
|
async function getRun(runId: number) {
|
||||||
@@ -412,7 +424,11 @@ const ITEM_SORTS: Record<string, string> = {
|
|||||||
async function getRunItems(runId: number, filters: URLSearchParams) {
|
async function getRunItems(runId: number, filters: URLSearchParams) {
|
||||||
const { page, pageSize, offset } = pageInput(filters);
|
const { page, pageSize, offset } = pageInput(filters);
|
||||||
const { where, params } = itemFilters(filters, runId);
|
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 }>(
|
const totalRow = await pgGet<{ total: string }>(
|
||||||
`SELECT COUNT(*) AS total FROM (${ITEM_ROWS}) item_rows ${where}`,
|
`SELECT COUNT(*) AS total FROM (${ITEM_ROWS}) item_rows ${where}`,
|
||||||
params,
|
params,
|
||||||
@@ -422,29 +438,60 @@ async function getRunItems(runId: number, filters: URLSearchParams) {
|
|||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
);
|
);
|
||||||
const total = Number(totalRow?.total ?? 0);
|
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) {
|
async function exportRunItems(runId: number, filters: URLSearchParams) {
|
||||||
const { where, params } = itemFilters(filters, runId);
|
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>>(
|
const rows = await pgAll<Record<string, unknown>>(
|
||||||
`SELECT * FROM (${ITEM_ROWS}) item_rows ${where} ORDER BY ${orderBy}`,
|
`SELECT * FROM (${ITEM_ROWS}) item_rows ${where} ORDER BY ${orderBy}`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
const headers = [
|
const headers = [
|
||||||
"run_id", "asin", "product_name", "brand", "category", "unit_cost",
|
"run_id",
|
||||||
"current_price", "avg_price_90d", "sales_rank_avg_90d", "seller_count",
|
"asin",
|
||||||
"amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold",
|
"product_name",
|
||||||
"sellability_status", "verdict", "confidence", "reasoning", "fetched_at",
|
"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) {
|
async function getProducts(filters: URLSearchParams) {
|
||||||
const { page, pageSize, offset } = pageInput(filters);
|
const { page, pageSize, offset } = pageInput(filters);
|
||||||
const { where, params } = itemFilters(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 = `
|
const base = `
|
||||||
SELECT product.asin, product.asin AS product_asin,
|
SELECT product.asin, product.asin AS product_asin,
|
||||||
latest.item_id, latest.run_id AS "runId", latest.process_type AS "processType",
|
latest.item_id, latest.run_id AS "runId", latest.process_type AS "processType",
|
||||||
@@ -466,23 +513,28 @@ async function getProducts(filters: URLSearchParams) {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) latest ON TRUE`;
|
) latest ON TRUE`;
|
||||||
const total = Number(
|
const total = Number(
|
||||||
(await pgGet<{ total: string }>(
|
(
|
||||||
|
await pgGet<{ total: string }>(
|
||||||
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
|
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
|
||||||
params,
|
params,
|
||||||
))?.total ?? 0,
|
)
|
||||||
|
)?.total ?? 0,
|
||||||
);
|
);
|
||||||
const items = await pgAll(
|
const items = await pgAll(
|
||||||
`SELECT * FROM (${base}) products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
`SELECT * FROM (${base}) products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, 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) {
|
async function getProduct(asin: string) {
|
||||||
const product = await pgGet(
|
const product = await pgGet(`SELECT * FROM products WHERE asin = ?`, [asin]);
|
||||||
`SELECT * FROM products WHERE asin = ?`,
|
|
||||||
[asin],
|
|
||||||
);
|
|
||||||
if (!product) return null;
|
if (!product) return null;
|
||||||
const observations = await pgAll(
|
const observations = await pgAll(
|
||||||
`SELECT observation.*, run.type AS run_type
|
`SELECT observation.*, run.type AS run_type
|
||||||
@@ -511,7 +563,9 @@ async function getProduct(asin: string) {
|
|||||||
const distributorResearch = distributorResearchRows.map((row) => {
|
const distributorResearch = distributorResearchRows.map((row) => {
|
||||||
const distributors = (() => {
|
const distributors = (() => {
|
||||||
try {
|
try {
|
||||||
return normalizeDistributorCandidates(JSON.parse(String(row.distributors_json ?? "[]")));
|
return normalizeDistributorCandidates(
|
||||||
|
JSON.parse(String(row.distributors_json ?? "[]")),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -519,7 +573,8 @@ async function getProduct(asin: string) {
|
|||||||
return {
|
return {
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
run_item_id: row.run_item_id == null ? null : Number(row.run_item_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 ?? ""),
|
provider: String(row.provider ?? ""),
|
||||||
model: String(row.model ?? ""),
|
model: String(row.model ?? ""),
|
||||||
status: String(row.status ?? ""),
|
status: String(row.status ?? ""),
|
||||||
@@ -531,6 +586,37 @@ async function getProduct(asin: string) {
|
|||||||
return { product, observations, analyses, distributorResearch };
|
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) {
|
async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
||||||
const row = await pgGet<Record<string, any>>(
|
const row = await pgGet<Record<string, any>>(
|
||||||
`SELECT ri.id, ri.run_id, ri.product_asin AS asin, r.type,
|
`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) throw new Error("Run item not found");
|
||||||
if (row.type === "supplier_upc") {
|
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 = {
|
const record: ProductRecord = {
|
||||||
asin: row.asin,
|
asin: row.asin,
|
||||||
@@ -592,15 +680,18 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
|||||||
spApi: spApi as SpApiData,
|
spApi: spApi as SpApiData,
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
const verdict =
|
const verdict = (await analyzeProducts([enriched], { useClaude }))[0] ?? {
|
||||||
(await analyzeProducts([enriched], { useClaude }))[0] ?? {
|
|
||||||
asin: row.asin,
|
asin: row.asin,
|
||||||
verdict: "SKIP" as const,
|
verdict: "SKIP" as const,
|
||||||
confidence: 0,
|
confidence: 0,
|
||||||
reasoning: "LLM analysis returned no verdict",
|
reasoning: "LLM analysis returned no verdict",
|
||||||
};
|
};
|
||||||
const result: AnalysisResult = { product: enriched, 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({
|
await db.insert(analysisRevisions).values({
|
||||||
runItemId: itemId,
|
runItemId: itemId,
|
||||||
observationId,
|
observationId,
|
||||||
@@ -611,7 +702,12 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
|||||||
analyzedAt: new Date(enriched.fetchedAt),
|
analyzedAt: new Date(enriched.fetchedAt),
|
||||||
});
|
});
|
||||||
await refreshRunStats(row.run_id);
|
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 = {
|
type DistributorCandidate = {
|
||||||
@@ -630,10 +726,15 @@ function clampDistributorConfidence(value: unknown): number {
|
|||||||
return Math.max(0, Math.min(100, Math.round(parsed)));
|
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 [];
|
if (!Array.isArray(payload)) return [];
|
||||||
return payload
|
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) => ({
|
.map((item) => ({
|
||||||
name: String(item.name ?? "").trim(),
|
name: String(item.name ?? "").trim(),
|
||||||
website: String(item.website ?? "").trim(),
|
website: String(item.website ?? "").trim(),
|
||||||
@@ -641,7 +742,9 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
|
|||||||
confidence: clampDistributorConfidence(item.confidence),
|
confidence: clampDistributorConfidence(item.confidence),
|
||||||
reputation: String(item.reputation ?? "").trim(),
|
reputation: String(item.reputation ?? "").trim(),
|
||||||
contactInfo: String(item.contact_info ?? item.contactInfo ?? "").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)
|
.filter((item) => item.name.length > 0 && item.website.length > 0)
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
@@ -650,7 +753,7 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
|
|||||||
function extractJsonArrayFromText(text: string): string {
|
function extractJsonArrayFromText(text: string): string {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
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 start = candidate.indexOf("[");
|
||||||
const end = candidate.lastIndexOf("]");
|
const end = candidate.lastIndexOf("]");
|
||||||
if (start >= 0 && end > start) {
|
if (start >= 0 && end > start) {
|
||||||
@@ -659,11 +762,14 @@ function extractJsonArrayFromText(text: string): string {
|
|||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestClaudeDistributorCandidates(context: Record<string, unknown>) {
|
async function requestClaudeDistributorCandidates(
|
||||||
|
context: Record<string, unknown>,
|
||||||
|
) {
|
||||||
if (!config.anthropicApiKey) {
|
if (!config.anthropicApiKey) {
|
||||||
throw new Error("Missing required env var: ANTHROPIC_API_KEY");
|
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 = [
|
const system = [
|
||||||
"You are a wholesale sourcing researcher who identifies authorized U.S. distributors for Amazon products.",
|
"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.",
|
"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();
|
const raw = await response.text();
|
||||||
if (!response.ok) {
|
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 = "";
|
let contentText = "";
|
||||||
try {
|
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 ?? [])
|
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 ?? "")
|
.map((block) => block.text ?? "")
|
||||||
.join("\n");
|
.join("\n");
|
||||||
} catch {
|
} catch {
|
||||||
@@ -817,7 +929,10 @@ async function findDistributorsForStalkerProduct(runItemId: number) {
|
|||||||
distributorsJson: JSON.stringify(claude.candidates),
|
distributorsJson: JSON.stringify(claude.candidates),
|
||||||
rawResponse: claude.rawResponse,
|
rawResponse: claude.rawResponse,
|
||||||
})
|
})
|
||||||
.returning({ id: productDistributorResearch.id, createdAt: productDistributorResearch.createdAt });
|
.returning({
|
||||||
|
id: productDistributorResearch.id,
|
||||||
|
createdAt: productDistributorResearch.createdAt,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
asin: row.asin,
|
asin: row.asin,
|
||||||
runItemId: runItemId,
|
runItemId: runItemId,
|
||||||
@@ -866,7 +981,10 @@ function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (product) {
|
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();
|
const verdict = filters.get("verdict")?.toUpperCase();
|
||||||
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
||||||
conditions.push("analysis.decision::text = ?");
|
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",
|
"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(
|
const items = await pgAll(
|
||||||
`SELECT * FROM (${base}) rows ORDER BY ${order} LIMIT ? OFFSET ?`,
|
`SELECT * FROM (${base}) rows ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
@@ -954,7 +1079,10 @@ async function getStalkerResults(filters: URLSearchParams) {
|
|||||||
sellers: Number(summary?.sellers ?? 0),
|
sellers: Number(summary?.sellers ?? 0),
|
||||||
persistedInventoryAsins: Number(summary?.persistedInventoryAsins ?? 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",
|
"monthly_sold DESC NULLS LAST, last_seen_at DESC, asin ASC",
|
||||||
);
|
);
|
||||||
if (exportOnly) return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
|
if (exportOnly)
|
||||||
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) products`, params))?.total ?? 0);
|
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(
|
const items = await pgAll(
|
||||||
`SELECT * FROM (${base}) products ORDER BY ${order} LIMIT ? OFFSET ?`,
|
`SELECT * FROM (${base}) products ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||||
[...params, pageSize, offset],
|
[...params, pageSize, offset],
|
||||||
@@ -1030,12 +1166,19 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
|
|||||||
sellers: Number(summary?.sellers ?? 0),
|
sellers: Number(summary?.sellers ?? 0),
|
||||||
products: Number(summary?.products ?? 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> {
|
async function exportStalkerProducts(
|
||||||
const rows = (await stalkerProducts(filters, true)) as Array<Record<string, any>>;
|
filters: URLSearchParams,
|
||||||
|
): Promise<Response> {
|
||||||
|
const rows = (await stalkerProducts(filters, true)) as Array<
|
||||||
|
Record<string, any>
|
||||||
|
>;
|
||||||
const data = rows.map((row) => ({
|
const data = rows.map((row) => ({
|
||||||
ASIN: row.asin,
|
ASIN: row.asin,
|
||||||
"Amazon URL": `https://amazon.com/dp/${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,
|
"Run ID": row.runId,
|
||||||
}));
|
}));
|
||||||
const workbook = XLSX.utils.book_new();
|
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(
|
return xlsx(
|
||||||
XLSX.write(workbook, { type: "array", bookType: "xlsx" }) as ArrayBuffer,
|
XLSX.write(workbook, { type: "array", bookType: "xlsx" }) as ArrayBuffer,
|
||||||
"stalker-sellable-products.xlsx",
|
"stalker-sellable-products.xlsx",
|
||||||
@@ -1082,13 +1229,17 @@ const server = Bun.serve({
|
|||||||
"/stalker": index,
|
"/stalker": index,
|
||||||
"/stalker/products": index,
|
"/stalker/products": index,
|
||||||
"/runs/:runId": 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) => {
|
"/api/runs/:runId": async (req) => {
|
||||||
const runId = Number(req.params.runId);
|
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") {
|
if (req.method === "DELETE") {
|
||||||
const deleted = await pgRun("DELETE FROM runs WHERE id = ?", [runId]);
|
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);
|
const run = await getRun(runId);
|
||||||
if (!run) return json({ error: "Run not found" }, 404);
|
if (!run) return json({ error: "Run not found" }, 404);
|
||||||
@@ -1106,57 +1257,121 @@ const server = Bun.serve({
|
|||||||
},
|
},
|
||||||
"/api/runs/:runId/items": async (req) => {
|
"/api/runs/:runId/items": async (req) => {
|
||||||
const runId = Number(req.params.runId);
|
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));
|
return json(await getRunItems(runId, new URL(req.url).searchParams));
|
||||||
},
|
},
|
||||||
"/api/runs/:runId/export.csv": async (req) => {
|
"/api/runs/:runId/export.csv": async (req) => {
|
||||||
const runId = Number(req.params.runId);
|
const runId = Number(req.params.runId);
|
||||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
if (!Number.isInteger(runId))
|
||||||
return csv(await exportRunItems(runId, new URL(req.url).searchParams), `run-${runId}.csv`);
|
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) => {
|
"/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);
|
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 {
|
try {
|
||||||
return json(await reanalyzeRunItem(itemId));
|
return json(await reanalyzeRunItem(itemId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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) => {
|
"/api/products/:asin": async (req) => {
|
||||||
const asin = normalizeAsin(req.params.asin);
|
const asin = normalizeAsin(req.params.asin);
|
||||||
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||||
const result = await getProduct(asin);
|
const result = await getProduct(asin);
|
||||||
return result ? json(result) : json({ error: "Product not found" }, 404);
|
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/results": async (req) =>
|
||||||
"/api/stalker/products": async (req) => json(await stalkerProducts(new URL(req.url).searchParams)),
|
json(await getStalkerResults(new URL(req.url).searchParams)),
|
||||||
"/api/stalker/products/export.xlsx": async (req) => exportStalkerProducts(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) => {
|
"/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);
|
const runItemId = Number(req.params.runItemId);
|
||||||
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
|
if (!Number.isInteger(runItemId))
|
||||||
const provider = new URL(req.url).searchParams.get("provider")?.trim().toLowerCase();
|
return json({ error: "Invalid run item identifier" }, 400);
|
||||||
|
const provider = new URL(req.url).searchParams
|
||||||
|
.get("provider")
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
const useClaude = provider === "claude";
|
const useClaude = provider === "claude";
|
||||||
try {
|
try {
|
||||||
return json(await reanalyzeRunItem(runItemId, useClaude || USE_CLAUDE));
|
return json(await reanalyzeRunItem(runItemId, useClaude || USE_CLAUDE));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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) => {
|
"/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);
|
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 {
|
try {
|
||||||
return json(await findDistributorsForStalkerProduct(runItemId));
|
return json(await findDistributorsForStalkerProduct(runItemId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(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) =>
|
"/api/stalker/purge": async (req) =>
|
||||||
@@ -1168,10 +1383,15 @@ const server = Bun.serve({
|
|||||||
const upcs = await parseUpcsFromRequest(req);
|
const upcs = await parseUpcsFromRequest(req);
|
||||||
const error = validateUpcs(upcs);
|
const error = validateUpcs(upcs);
|
||||||
if (error) return json({ error }, 400);
|
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 });
|
return json({ requested: upcs.length, matched: items.length, items });
|
||||||
} catch (error) {
|
} 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) => {
|
"/api/upc/lookup": async (req) => {
|
||||||
@@ -1180,16 +1400,31 @@ const server = Bun.serve({
|
|||||||
const error = validateUpcs(upcs);
|
const error = validateUpcs(upcs);
|
||||||
if (error) return json({ error }, 400);
|
if (error) return json({ error }, 400);
|
||||||
const items = [...(await lookupKeepaUpcs(upcs)).values()];
|
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) {
|
} 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) => {
|
"/api/process/upc-file": async (req) => {
|
||||||
try {
|
try {
|
||||||
return json(await runUpcFileAnalysis({ ...(await parseUpcFileRequest(req)), manageResources: false }));
|
return json(
|
||||||
|
await runUpcFileAnalysis({
|
||||||
|
...(await parseUpcFileRequest(req)),
|
||||||
|
manageResources: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
return json(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
function statusBadgeClass(status: string): string {
|
||||||
if (status === "ok" || status === "completed") return "badge badge-ok";
|
if (status === "ok" || status === "completed") return "badge badge-ok";
|
||||||
if (status === "failed") return "badge badge-failed";
|
if (status === "failed") return "badge badge-failed";
|
||||||
@@ -367,18 +446,90 @@ function Dashboard({
|
|||||||
onOpenStalker: () => void;
|
onOpenStalker: () => void;
|
||||||
onOpenStalkerProducts: () => void;
|
onOpenStalkerProducts: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const viewKey = "dashboard";
|
||||||
|
const initialState = useMemo(() => readViewState(viewKey), []);
|
||||||
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||||
const [processType, setProcessType] = useState("");
|
const [processType, setProcessType] = useState(String(initialState.processType ?? ""));
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState(String(initialState.status ?? ""));
|
||||||
const [startDate, setStartDate] = useState("");
|
const [startDate, setStartDate] = useState(String(initialState.startDate ?? ""));
|
||||||
const [endDate, setEndDate] = useState("");
|
const [endDate, setEndDate] = useState(String(initialState.endDate ?? ""));
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "timestamp", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({
|
||||||
|
field: String(initialState.sortField ?? "timestamp"),
|
||||||
|
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||||
|
});
|
||||||
const [refreshTick, setRefreshTick] = useState(0);
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
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(() => {
|
const summary = useMemo(() => {
|
||||||
if (!runs) return { total: 0, fba: 0, fbm: 0, skip: 0 };
|
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="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -596,21 +755,96 @@ function RunDetails({
|
|||||||
runId: number;
|
runId: number;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const viewKey = `run-details:${runId}`;
|
||||||
|
const initialState = useMemo(() => readViewState(viewKey), [viewKey]);
|
||||||
const [run, setRun] = useState<RunDetail | null>(null);
|
const [run, setRun] = useState<RunDetail | null>(null);
|
||||||
const [results, setResults] = useState<ResultsResponse | null>(null);
|
const [results, setResults] = useState<ResultsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||||
const [verdict, setVerdict] = useState("");
|
const [verdict, setVerdict] = useState(String(initialState.verdict ?? ""));
|
||||||
const [sellabilityStatus, setSellabilityStatus] = useState("");
|
const [sellabilityStatus, setSellabilityStatus] = useState(String(initialState.sellabilityStatus ?? ""));
|
||||||
const [amazonSellerFilter, setAmazonSellerFilter] =
|
const [amazonSellerFilter, setAmazonSellerFilter] =
|
||||||
useState<AmazonSellerFilter>("");
|
useState<AmazonSellerFilter>(initialState.amazonSellerFilter === "yes" || initialState.amazonSellerFilter === "no" ? initialState.amazonSellerFilter : "");
|
||||||
const [minConfidence, setMinConfidence] = useState("");
|
const [minConfidence, setMinConfidence] = useState(String(initialState.minConfidence ?? ""));
|
||||||
const [maxConfidence, setMaxConfidence] = useState("");
|
const [maxConfidence, setMaxConfidence] = useState(String(initialState.maxConfidence ?? ""));
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({
|
||||||
|
field: String(initialState.sortField ?? "monthly_sold"),
|
||||||
|
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||||
|
});
|
||||||
const [refreshTick, setRefreshTick] = useState(0);
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
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(() => {
|
const anomalies = useMemo(() => {
|
||||||
if (!results) return [] as ResultItem[];
|
if (!results) return [] as ResultItem[];
|
||||||
@@ -755,6 +989,14 @@ function RunDetails({
|
|||||||
<option value="50">50 / page</option>
|
<option value="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</select>
|
</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 style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 10 }}>
|
||||||
<a
|
<a
|
||||||
@@ -773,7 +1015,7 @@ function RunDetails({
|
|||||||
<div className="anomaly-list" style={{ marginTop: 8 }}>
|
<div className="anomaly-list" style={{ marginTop: 8 }}>
|
||||||
{anomalies.slice(0, 8).map((item) => (
|
{anomalies.slice(0, 8).map((item) => (
|
||||||
<div key={`anom-${item.asin}-${item.fetched_at}`} className="anomaly-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> : "-"}
|
{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}
|
||||||
<span>{detectAnomaly(item)}</span>
|
<span>{detectAnomaly(item)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -810,7 +1052,7 @@ function RunDetails({
|
|||||||
) : results?.items.length ? (
|
) : results?.items.length ? (
|
||||||
results.items.map((item) => (
|
results.items.map((item) => (
|
||||||
<tr key={`${item.asin}-${item.fetched_at}`}>
|
<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>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
||||||
<td>{formatNumber(item.monthly_sold)}</td>
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
<td>{formatNumber(item.seller_count)}</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 [items, setItems] = useState<ProductListResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||||
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
|
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(String(initialState.activeVerdict ?? verdict) as VerdictFilter);
|
||||||
const [amazonSellerFilter, setAmazonSellerFilter] =
|
const [amazonSellerFilter, setAmazonSellerFilter] =
|
||||||
useState<AmazonSellerFilter>("");
|
useState<AmazonSellerFilter>(initialState.amazonSellerFilter === "yes" || initialState.amazonSellerFilter === "no" ? initialState.amazonSellerFilter : "");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
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 [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(() => {
|
useEffect(() => {
|
||||||
setActiveVerdict(verdict);
|
setActiveVerdict(verdict);
|
||||||
@@ -958,6 +1266,14 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
|||||||
<option value="50">50 / page</option>
|
<option value="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -988,7 +1304,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
|||||||
) : items?.items.length ? (
|
) : items?.items.length ? (
|
||||||
items.items.map((item) => (
|
items.items.map((item) => (
|
||||||
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
|
<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>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
||||||
<td>{formatNumber(item.monthly_sold)}</td>
|
<td>{formatNumber(item.monthly_sold)}</td>
|
||||||
<td>{formatNumber(item.seller_count)}</td>
|
<td>{formatNumber(item.seller_count)}</td>
|
||||||
@@ -1040,18 +1356,90 @@ function StalkerExplorer({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onOpenProducts: () => void;
|
onOpenProducts: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const viewKey = "stalker";
|
||||||
|
const initialState = useMemo(() => readViewState(viewKey), []);
|
||||||
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
|
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [purging, setPurging] = useState(false);
|
const [purging, setPurging] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||||
const [sellerId, setSellerId] = useState("");
|
const [sellerId, setSellerId] = useState(String(initialState.sellerId ?? ""));
|
||||||
const [runId, setRunId] = useState("");
|
const [runId, setRunId] = useState(String(initialState.runId ?? ""));
|
||||||
const [minRatingCount, setMinRatingCount] = useState("1");
|
const [minRatingCount, setMinRatingCount] = useState(String(initialState.minRatingCount ?? "1"));
|
||||||
const [maxRatingCount, setMaxRatingCount] = useState("30");
|
const [maxRatingCount, setMaxRatingCount] = useState(String(initialState.maxRatingCount ?? "30"));
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||||
const [pageSize, setPageSize] = useState(25);
|
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "persisted_inventory_asin_count", direction: "DESC" });
|
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 [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -1144,6 +1532,14 @@ function StalkerExplorer({
|
|||||||
<option value="50">50 / page</option>
|
<option value="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -1220,31 +1616,148 @@ function StalkerProductsExplorer({
|
|||||||
onOpenSellers: () => void;
|
onOpenSellers: () => void;
|
||||||
onOpenProduct: (asin: string, runItemId: number | null) => 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 [results, setResults] = useState<StalkerProductsResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||||
const [sellerId, setSellerId] = useState("");
|
const [sellerId, setSellerId] = useState(String(initialState.sellerId ?? ""));
|
||||||
const [runId, setRunId] = useState("");
|
const [runId, setRunId] = useState(String(initialState.runId ?? ""));
|
||||||
const [verdict, setVerdict] = useState("");
|
const [verdict, setVerdict] = useState(String(initialState.verdict ?? ""));
|
||||||
const [amazonIsSeller, setAmazonIsSeller] = useState("");
|
const [amazonIsSeller, setAmazonIsSeller] = useState(String(initialState.amazonIsSeller ?? ""));
|
||||||
const [minPrice, setMinPrice] = useState("");
|
const [minPrice, setMinPrice] = useState(String(initialState.minPrice ?? ""));
|
||||||
const [maxPrice, setMaxPrice] = useState("");
|
const [maxPrice, setMaxPrice] = useState(String(initialState.maxPrice ?? ""));
|
||||||
const [minMonthlySold, setMinMonthlySold] = useState("");
|
const [minMonthlySold, setMinMonthlySold] = useState(String(initialState.minMonthlySold ?? ""));
|
||||||
const [maxMonthlySold, setMaxMonthlySold] = useState("");
|
const [maxMonthlySold, setMaxMonthlySold] = useState(String(initialState.maxMonthlySold ?? ""));
|
||||||
const [minSalesRank, setMinSalesRank] = useState("");
|
const [minSalesRank, setMinSalesRank] = useState(String(initialState.minSalesRank ?? ""));
|
||||||
const [maxSalesRank, setMaxSalesRank] = useState("");
|
const [maxSalesRank, setMaxSalesRank] = useState(String(initialState.maxSalesRank ?? ""));
|
||||||
const [minSellerCount, setMinSellerCount] = useState("");
|
const [minSellerCount, setMinSellerCount] = useState(String(initialState.minSellerCount ?? ""));
|
||||||
const [maxSellerCount, setMaxSellerCount] = useState("");
|
const [maxSellerCount, setMaxSellerCount] = useState(String(initialState.maxSellerCount ?? ""));
|
||||||
const [minRatingCount, setMinRatingCount] = useState("");
|
const [minRatingCount, setMinRatingCount] = useState(String(initialState.minRatingCount ?? ""));
|
||||||
const [maxRatingCount, setMaxRatingCount] = useState("");
|
const [maxRatingCount, setMaxRatingCount] = useState(String(initialState.maxRatingCount ?? ""));
|
||||||
const [minConfidence, setMinConfidence] = useState("");
|
const [minConfidence, setMinConfidence] = useState(String(initialState.minConfidence ?? ""));
|
||||||
const [maxConfidence, setMaxConfidence] = useState("");
|
const [maxConfidence, setMaxConfidence] = useState(String(initialState.maxConfidence ?? ""));
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 50) || 50);
|
||||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
const [sort, setSort] = useState<SortState>({
|
||||||
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
|
field: String(initialState.sortField ?? "monthly_sold"),
|
||||||
const [showSellerColumn, setShowSellerColumn] = useState(false);
|
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||||
const [showCategoryColumn, setShowCategoryColumn] = useState(false);
|
});
|
||||||
|
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 {
|
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -1399,6 +1912,14 @@ function StalkerProductsExplorer({
|
|||||||
<option value="50">50 / page</option>
|
<option value="50">50 / page</option>
|
||||||
<option value="100">100 / page</option>
|
<option value="100">100 / page</option>
|
||||||
</select>
|
</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>
|
<button onClick={resetFilters}>Reset filters</button>
|
||||||
<a className="button-link" href={exportHref}>Export XLSX</a>
|
<a className="button-link" href={exportHref}>Export XLSX</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -1514,10 +2035,13 @@ function ProductDetails({
|
|||||||
}, [asin]);
|
}, [asin]);
|
||||||
|
|
||||||
async function reanalyze() {
|
async function reanalyze() {
|
||||||
if (effectiveRunItemId == null || reanalyzing) return;
|
if (reanalyzing) return;
|
||||||
setReanalyzing(true);
|
setReanalyzing(true);
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
window.alert(body?.error ?? "Failed to re-run analysis");
|
window.alert(body?.error ?? "Failed to re-run analysis");
|
||||||
@@ -1530,16 +2054,18 @@ function ProductDetails({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function discoverDistributors() {
|
async function discoverDistributors() {
|
||||||
if (effectiveRunItemId == null || findingDistributors) return;
|
if (findingDistributors) return;
|
||||||
setFindingDistributors(true);
|
setFindingDistributors(true);
|
||||||
try {
|
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) {
|
if (!res.ok) {
|
||||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||||
window.alert(body?.error ?? "Failed to find distributors");
|
window.alert(body?.error ?? "Failed to find distributors");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// network error or timeout — job may have completed on the server anyway
|
|
||||||
} finally {
|
} finally {
|
||||||
load();
|
load();
|
||||||
setFindingDistributors(false);
|
setFindingDistributors(false);
|
||||||
@@ -1552,7 +2078,6 @@ function ProductDetails({
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>{data?.product.name ?? asin}</h2>
|
<h2>{data?.product.name ?? asin}</h2>
|
||||||
{effectiveRunItemId != null && (
|
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button onClick={reanalyze} disabled={reanalyzing}>
|
<button onClick={reanalyze} disabled={reanalyzing}>
|
||||||
{reanalyzing ? "Re-running..." : "Re-run analysis"}
|
{reanalyzing ? "Re-running..." : "Re-run analysis"}
|
||||||
@@ -1561,7 +2086,6 @@ function ProductDetails({
|
|||||||
{findingDistributors ? "Finding distributors..." : "Find distributors"}
|
{findingDistributors ? "Finding distributors..." : "Find distributors"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="meta-grid" style={{ marginTop: 12 }}>
|
<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>
|
<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: "dashboard" }
|
||||||
| { kind: "run"; processType: ProcessType; runId: number }
|
| { kind: "run"; processType: ProcessType; runId: number }
|
||||||
| { kind: "products"; verdict: VerdictFilter }
|
| { kind: "products"; verdict: VerdictFilter }
|
||||||
| { kind: "product"; asin: string; runItemId?: number | null }
|
|
||||||
| { kind: "stalker" }
|
| { kind: "stalker" }
|
||||||
| { kind: "stalker-products" };
|
| { kind: "stalker-products" };
|
||||||
|
|
||||||
|
type AppRoute = NonProductRoute | { kind: "product"; asin: string; runItemId?: number | null; backTo?: NonProductRoute };
|
||||||
|
|
||||||
function parseRoute(pathname: string, search: string): AppRoute {
|
function parseRoute(pathname: string, search: string): AppRoute {
|
||||||
const runMatch = pathname.match(/^\/runs\/(\d+)$/);
|
const runMatch = pathname.match(/^\/runs\/(\d+)$/);
|
||||||
if (runMatch) {
|
if (runMatch) {
|
||||||
@@ -1710,6 +2235,14 @@ function parseRoute(pathname: string, search: string): AppRoute {
|
|||||||
return { kind: "dashboard" };
|
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() {
|
function App() {
|
||||||
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
|
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
|
||||||
|
|
||||||
@@ -1746,21 +2279,31 @@ function App() {
|
|||||||
setRoute({ kind: "dashboard" });
|
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) {
|
function openProduct(asin: string, runItemId: number | null) {
|
||||||
history.pushState({}, "", `/products/${asin}`);
|
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") {
|
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") {
|
if (route.kind === "products") {
|
||||||
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
return <ProductList verdict={route.verdict} onBack={backToDashboard} onOpenProduct={openProduct} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.kind === "product") {
|
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") {
|
if (route.kind === "stalker") {
|
||||||
|
|||||||
Reference in New Issue
Block a user