diff --git a/src/server.ts b/src/server.ts index f33d600..d1982eb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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>( query: string, params: unknown[] = [], ): Promise { - const rows = await client.unsafe(toPostgresSql(query), params as never[]); + const rows = await client.unsafe( + toPostgresSql(query), + params as never[], + ); return (rows[0] as T) ?? null; } @@ -54,7 +54,10 @@ async function pgAll>( query: string, params: unknown[] = [], ): Promise { - return client.unsafe(toPostgresSql(query), params as never[]) as unknown as T[]; + return client.unsafe( + toPostgresSql(query), + params as never[], + ) as unknown as T[]; } async function pgRun(query: string, params: unknown[] = []): Promise { @@ -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 = { 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>( `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 ?? ""), @@ -543,7 +598,10 @@ async function findLatestRunItemIdByAsin(asin: string): Promise { return row?.id == null ? null : Number(row.id); } -async function reanalyzeStalkerProductByAsin(asin: string, useClaude = USE_CLAUDE) { +async function reanalyzeStalkerProductByAsin( + asin: string, + useClaude = USE_CLAUDE, +) { const runItemId = await findLatestRunItemIdByAsin(asin); if (runItemId == null) { throw new Error("Stalker product item not found"); @@ -579,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, @@ -620,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, @@ -639,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 = { @@ -658,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 => item != null && typeof item === "object") + .filter( + (item): item is Record => + item != null && typeof item === "object", + ) .map((item) => ({ name: String(item.name ?? "").trim(), website: String(item.website ?? "").trim(), @@ -669,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); @@ -678,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) { @@ -687,11 +762,14 @@ function extractJsonArrayFromText(text: string): string { return candidate; } -async function requestClaudeDistributorCandidates(context: Record) { +async function requestClaudeDistributorCandidates( + context: Record, +) { 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.", @@ -735,13 +813,19 @@ async function requestClaudeDistributorCandidates(context: Record }; + 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 { @@ -845,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, @@ -894,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 = ?"); @@ -964,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], @@ -982,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)), }; } @@ -1040,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], @@ -1058,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 { - const rows = (await stalkerProducts(filters, true)) as Array>; +async function exportStalkerProducts( + filters: URLSearchParams, +): Promise { + const rows = (await stalkerProducts(filters, true)) as Array< + Record + >; const data = rows.map((row) => ({ ASIN: row.asin, "Amazon URL": `https://amazon.com/dp/${row.asin}`, @@ -1081,7 +1196,11 @@ async function exportStalkerProducts(filters: URLSearchParams): Promise 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); @@ -1134,81 +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); + 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 provider = new URL(req.url).searchParams + .get("provider") + ?.trim() + .toLowerCase(); const useClaude = provider === "claude"; try { - return json(await reanalyzeStalkerProductByAsin(asin, useClaude || USE_CLAUDE)); + 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); + 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); + 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); + return json( + { error: message }, + message === "Stalker product item not found" ? 404 : 500, + ); } }, "/api/stalker/purge": async (req) => @@ -1220,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) => { @@ -1232,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, + ); } }, }, diff --git a/src/web/frontend.tsx b/src/web/frontend.tsx index d2004e0..ec256d6 100644 --- a/src/web/frontend.tsx +++ b/src/web/frontend.tsx @@ -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; + updatedAt: string; +}; + +function readJsonStorage(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(key: string, value: T): void { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + } +} + +function readViewState(viewKey: string): Record { + const all = readJsonStorage>>(VIEW_STATE_KEY, {}); + const value = all[viewKey]; + return value && typeof value === "object" ? value : {}; +} + +function saveViewState(viewKey: string, state: Record): void { + const all = readJsonStorage>>(VIEW_STATE_KEY, {}); + all[viewKey] = state; + writeJsonStorage(VIEW_STATE_KEY, all); +} + +function listFilterPresets(viewKey: string): SavedFilterPreset[] { + const all = readJsonStorage(FILTER_PRESETS_KEY, []); + return all.filter((preset) => preset.viewKey === viewKey); +} + +function saveFilterPreset(viewKey: string, name: string, state: Record): SavedFilterPreset { + const all = readJsonStorage(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): SavedFilterPreset | null { + const all = readJsonStorage(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(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({ 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({ + field: String(initialState.sortField ?? "timestamp"), + direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC", + }); const [refreshTick, setRefreshTick] = useState(0); const [deletingKey, setDeletingKey] = useState(null); + const [presetName, setPresetName] = useState(""); + const [selectedPresetId, setSelectedPresetId] = useState(""); + const [presets, setPresets] = useState(() => listFilterPresets(viewKey)); + + function snapshotFilters(): Record { + return { + search, + processType, + status, + startDate, + endDate, + page, + pageSize, + sortField: sort.field, + sortDirection: sort.direction, + }; + } + + function applyFilterState(state: Record) { + 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({ + setPresetName(e.target.value)} placeholder="Preset name" /> + + + + @@ -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(null); const [results, setResults] = useState(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(""); - const [minConfidence, setMinConfidence] = useState(""); - const [maxConfidence, setMaxConfidence] = useState(""); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(25); - const [sort, setSort] = useState({ field: "monthly_sold", direction: "DESC" }); + useState(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({ + field: String(initialState.sortField ?? "monthly_sold"), + direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC", + }); const [refreshTick, setRefreshTick] = useState(0); const [reanalyzing, setReanalyzing] = useState>({}); + const [presetName, setPresetName] = useState(""); + const [selectedPresetId, setSelectedPresetId] = useState(""); + const [presets, setPresets] = useState(() => 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 { + return { + search, + verdict, + sellabilityStatus, + amazonSellerFilter, + minConfidence, + maxConfidence, + page, + pageSize, + sortField: sort.field, + sortDirection: sort.direction, + }; + } + + function applyFilterState(state: Record) { + 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({ + setPresetName(e.target.value)} placeholder="Preset name" /> + + + +
{anomalies.slice(0, 8).map((item) => (
- {item.product_asin ? {item.asin} : item.asin} + {item.product_asin ? : item.asin} {item.verdict ? {item.verdict} : "-"} {detectAnomaly(item)}
@@ -810,7 +1052,7 @@ function RunDetails({ ) : results?.items.length ? ( results.items.map((item) => ( - {item.product_asin ? {item.asin} : item.asin} + {item.product_asin ? : item.asin} {item.verdict ? {item.verdict} : "-"} {formatNumber(item.monthly_sold)} {formatNumber(item.seller_count)} @@ -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(null); const [loading, setLoading] = useState(false); - const [search, setSearch] = useState(""); - const [activeVerdict, setActiveVerdict] = useState(verdict); + const [search, setSearch] = useState(String(initialState.search ?? "")); + const [activeVerdict, setActiveVerdict] = useState(String(initialState.activeVerdict ?? verdict) as VerdictFilter); const [amazonSellerFilter, setAmazonSellerFilter] = - useState(""); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(25); - const [sort, setSort] = useState({ field: "monthly_sold", direction: "DESC" }); + useState(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({ + field: String(initialState.sortField ?? "monthly_sold"), + direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC", + }); const [reanalyzing, setReanalyzing] = useState>({}); + const [presetName, setPresetName] = useState(""); + const [selectedPresetId, setSelectedPresetId] = useState(""); + const [presets, setPresets] = useState(() => 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 { + return { + search, + activeVerdict, + amazonSellerFilter, + page, + pageSize, + sortField: sort.field, + sortDirection: sort.direction, + }; + } + + function applyFilterState(state: Record) { + 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: () = + setPresetName(e.target.value)} placeholder="Preset name" /> + + + +
@@ -988,7 +1304,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () = ) : items?.items.length ? ( items.items.map((item) => ( - {item.asin} + {item.verdict ? {item.verdict} : "-"} {formatNumber(item.monthly_sold)} {formatNumber(item.seller_count)} @@ -1040,18 +1356,90 @@ function StalkerExplorer({ onBack: () => void; onOpenProducts: () => void; }) { + const viewKey = "stalker"; + const initialState = useMemo(() => readViewState(viewKey), []); const [results, setResults] = useState(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({ 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({ + 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(() => 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 { + return { + search, + sellerId, + runId, + minRatingCount, + maxRatingCount, + page, + pageSize, + sortField: sort.field, + sortDirection: sort.direction, + }; + } + + function applyFilterState(state: Record) { + 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({ + setPresetName(e.target.value)} placeholder="Preset name" /> + + + +
@@ -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(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({ 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({ + 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(() => 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 { + 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) { + 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({ + setPresetName(e.target.value)} placeholder="Preset name" /> + + + + Export XLSX @@ -1676,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) { @@ -1713,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(() => parseRoute(window.location.pathname, window.location.search)); @@ -1749,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 ; + return ; } if (route.kind === "products") { - return ; + return ; } if (route.kind === "product") { - return ; + return ; } if (route.kind === "stalker") {