diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1e7b3b8 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -v \"^$\")" + ] + } +} diff --git a/src/server.ts b/src/server.ts index 4db9ed3..601cd8d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import index from "./web/index.html"; 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, @@ -639,8 +640,8 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[ rationale: String(item.rationale ?? "").trim(), confidence: clampDistributorConfidence(item.confidence), reputation: String(item.reputation ?? "").trim(), - contactInfo: String(item.contact_info ?? "").trim(), - outreachDraft: String(item.outreach_draft ?? "").trim(), + contactInfo: String(item.contact_info ?? item.contactInfo ?? "").trim(), + outreachDraft: String(item.outreach_draft ?? item.outreachDraft ?? "").trim(), })) .filter((item) => item.name.length > 0 && item.website.length > 0) .slice(0, 10); @@ -674,8 +675,8 @@ async function requestClaudeDistributorCandidates(context: Record; raw_response: string | null; }>; @@ -309,6 +312,21 @@ function verdictBadgeClass(verdict: string): string { return "badge badge-skip"; } +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + function copy() { + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + return ( + + ); +} + function TinyBar({ fba, fbm, skip }: { fba: number; fbm: number; skip: number }) { const total = Math.max(1, fba + fbm + skip); const fbaPct = (fba / total) * 100; @@ -1196,9 +1214,11 @@ function StalkerExplorer({ function StalkerProductsExplorer({ onBack, onOpenSellers, + onOpenProduct, }: { onBack: () => void; onOpenSellers: () => void; + onOpenProduct: (asin: string, runItemId: number | null) => void; }) { const [results, setResults] = useState(null); const [loading, setLoading] = useState(false); @@ -1225,8 +1245,6 @@ function StalkerProductsExplorer({ const [showSellerIdColumn, setShowSellerIdColumn] = useState(false); const [showSellerColumn, setShowSellerColumn] = useState(false); const [showCategoryColumn, setShowCategoryColumn] = useState(false); - const [reanalyzing, setReanalyzing] = useState>({}); - const [findingDistributors, setFindingDistributors] = useState>({}); function buildStalkerProductParams(includePaging: boolean): URLSearchParams { const params = new URLSearchParams({ @@ -1318,64 +1336,6 @@ function StalkerProductsExplorer({ setPage(1); } - async function reanalyzeStalkerItem(item: StalkerProductItem) { - if (item.run_item_id == null) { - window.alert("No analysis item available for this row yet."); - return; - } - const key = String(item.run_item_id); - if (reanalyzing[key]) return; - setReanalyzing((prev) => ({ ...prev, [key]: true })); - try { - const response = await fetch(`/api/stalker/products/${item.run_item_id}/reanalyze?provider=claude`, { - method: "POST", - }); - if (!response.ok) { - const payload = (await response.json().catch(() => null)) as { error?: string } | null; - window.alert(payload?.error ?? "Failed to re-run analysis"); - return; - } - const params = buildStalkerProductParams(true); - const res = await fetch(`/api/stalker/products?${params.toString()}`); - const payload = (await res.json()) as StalkerProductsResponse; - setResults(payload); - } finally { - setReanalyzing((prev) => { - const next = { ...prev }; - delete next[key]; - return next; - }); - } - } - - async function discoverDistributors(item: StalkerProductItem) { - if (item.run_item_id == null) { - window.alert("No analysis item available for this row yet."); - return; - } - const key = String(item.run_item_id); - if (findingDistributors[key]) return; - setFindingDistributors((prev) => ({ ...prev, [key]: true })); - try { - const response = await fetch(`/api/stalker/products/${item.run_item_id}/distributors`, { - method: "POST", - }); - const payload = (await response.json().catch(() => null)) as { - error?: string; - } | null; - if (!response.ok) { - window.alert(payload?.error ?? "Failed to find distributors"); - return; - } - } finally { - setFindingDistributors((prev) => { - const next = { ...prev }; - delete next[key]; - return next; - }); - } - } - const exportHref = `/api/stalker/products/export.xlsx?${buildStalkerProductParams(false).toString()}`; return ( @@ -1502,20 +1462,9 @@ function StalkerProductsExplorer({ {item.runId} {formatDate(item.last_seen_at)} -
- - -
+ ); @@ -1541,30 +1490,79 @@ function StalkerProductsExplorer({ function ProductDetails({ asin, + runItemId, onBack, }: { asin: string; + runItemId?: number | null; onBack: () => void; }) { const [data, setData] = useState(null); + const [reanalyzing, setReanalyzing] = useState(false); + const [findingDistributors, setFindingDistributors] = useState(false); + + const effectiveRunItemId = runItemId ?? data?.distributorResearch[0]?.run_item_id ?? null; + + function load() { + fetch(`/api/products/${encodeURIComponent(asin)}`) + .then((r) => r.json()) + .then((payload: ProductHistoryResponse) => setData(payload)); + } useEffect(() => { - let cancelled = false; - fetch(`/api/products/${encodeURIComponent(asin)}`) - .then((response) => response.json()) - .then((payload: ProductHistoryResponse) => { - if (!cancelled) setData(payload); - }); - return () => { - cancelled = true; - }; + load(); }, [asin]); + async function reanalyze() { + if (effectiveRunItemId == null || reanalyzing) return; + setReanalyzing(true); + try { + const res = await fetch(`/api/stalker/products/${effectiveRunItemId}/reanalyze?provider=claude`, { method: "POST" }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + window.alert(body?.error ?? "Failed to re-run analysis"); + return; + } + load(); + } finally { + setReanalyzing(false); + } + } + + async function discoverDistributors() { + if (effectiveRunItemId == null || findingDistributors) return; + setFindingDistributors(true); + try { + const res = await fetch(`/api/stalker/products/${effectiveRunItemId}/distributors`, { method: "POST" }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + window.alert(body?.error ?? "Failed to find distributors"); + } + } catch { + // network error or timeout — job may have completed on the server anyway + } finally { + load(); + setFindingDistributors(false); + } + } + return (
-

{data?.product.name ?? asin}

+
+

{data?.product.name ?? asin}

+ {effectiveRunItemId != null && ( +
+ + +
+ )} +
ASIN: {asin}
Brand: {data?.product.brand ?? "-"}
@@ -1593,31 +1591,62 @@ function ProductDetails({

Distributor Research

-
- - - - {data?.distributorResearch.length ? data.distributorResearch.map((entry) => ( - - - - - - - - - )) : } - -
CreatedProviderModelStatusDistributorsRun Item
{formatDate(entry.created_at)}{entry.provider}{entry.model}{entry.status} - {entry.distributors.length ? entry.distributors.map((distributor, idx) => ( -
-
{distributor.name} ({formatNumber(distributor.confidence)}%)
- -
{distributor.rationale || "-"}
+ {data?.distributorResearch.length ? data.distributorResearch.map((entry) => ( +
+
+ {formatDate(entry.created_at)} + {entry.status} + {entry.run_item_id != null && Run item {entry.run_item_id}} +
+ {entry.distributors.length ? ( +
+ {entry.distributors.map((d, idx) => ( +
+
+ {d.name} + {formatNumber(d.confidence)}% confidence +
+
+ Website + {d.website} +
+ {d.rationale && ( +
+ Why a candidate + {d.rationale}
- )) : "-"} -
{entry.run_item_id ?? "-"}
No distributor research yet
-
+ )} + {d.reputation && ( +
+ Reputation + {d.reputation} +
+ )} + {d.contactInfo && ( +
+ Point of contact + {d.contactInfo} +
+ )} + {d.outreachDraft && ( +
+
+ Outreach draft + +
+
{d.outreachDraft}
+
+ )} +
+ ))} +
+ ) : ( +

No distributor candidates found in this research run.

+ )} +
+ )) : ( +

No distributor research yet. Use "Find distributors" to start.

+ )}

Observations

@@ -1648,7 +1677,7 @@ type AppRoute = | { kind: "dashboard" } | { kind: "run"; processType: ProcessType; runId: number } | { kind: "products"; verdict: VerdictFilter } - | { kind: "product"; asin: string } + | { kind: "product"; asin: string; runItemId?: number | null } | { kind: "stalker" } | { kind: "stalker-products" }; @@ -1717,6 +1746,11 @@ function App() { setRoute({ kind: "dashboard" }); } + function openProduct(asin: string, runItemId: number | null) { + history.pushState({}, "", `/products/${asin}`); + setRoute({ kind: "product", asin, runItemId }); + } + if (route.kind === "run") { return ; } @@ -1726,7 +1760,7 @@ function App() { } if (route.kind === "product") { - return ; + return ; } if (route.kind === "stalker") { @@ -1734,7 +1768,7 @@ function App() { } if (route.kind === "stalker-products") { - return ; + return ; } return ( diff --git a/src/web/styles.css b/src/web/styles.css index 38d36a5..4ab0d39 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -148,6 +148,87 @@ td { align-items: center; } +.dist-research-entry { + padding: 16px 0; + border-top: 1px solid #eceef0; +} + +.dist-research-entry:first-child { + border-top: none; + padding-top: 8px; +} + +.dist-entry-meta { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #5f6b7a; + margin-bottom: 12px; +} + +.dist-entry-run { + font-size: 12px; + color: #8a95a0; +} + +.dist-candidates { + display: flex; + flex-direction: column; + gap: 12px; +} + +.dist-candidate-card { + border: 1px solid #e7e8ea; + border-radius: 10px; + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dist-candidate-header { + display: flex; + align-items: center; + gap: 10px; + font-size: 15px; +} + +.dist-field { + display: flex; + gap: 8px; + font-size: 13px; + line-height: 1.5; +} + +.dist-field-block { + flex-direction: column; + gap: 4px; +} + +.dist-label { + font-weight: 600; + color: #445060; + white-space: nowrap; + min-width: 120px; +} + +.dist-field-block .dist-label { + min-width: unset; +} + +.dist-outreach { + background: #f7f8fa; + border: 1px solid #e7e8ea; + border-radius: 8px; + padding: 12px 14px; + font-family: inherit; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + margin: 0; +} + th { background: #fafafb; font-weight: 600;